Skip to content

Note

Click here to download the full example code

Normalizing Neurons#

Bring neurons into a canonical pose for machine-learning models.

NAVis ships a small navis.ml module with helpers for preparing neurons as model inputs. This first tutorial covers normalization; the sampling and augmentation tutorials cover the other two steps.

Note

These functions live under the navis.ml namespace (e.g. navis.ml.normalize_neuron) and are not lifted to the top level. See the Machine Learning API reference for the full list.

Why normalize?#

A neuron means the same thing no matter where it sits, how it is oriented or what units it was reconstructed in. A learning-based model should not have to spend capacity discovering that. navis.ml.normalize_neuron removes those nuisance degrees of freedom up front by mapping every neuron into a canonical frame with a single rigid-plus-uniform-scale transform:

\[ p' = s R (p - c) \]
  • center (c) moves a chosen reference point to the origin,
  • rotate (R) aligns the principal axes of the arbor with x/y/z, and
  • scale (s) normalizes the overall size.

Let's see it in action.

import navis
import numpy as np
import matplotlib.pyplot as plt

navis.config.pbar_hide = True

# Load an example skeleton (coordinates are in nanometers)
n = navis.example_neurons(1, kind="skeleton")

# Normalize its pose (defaults: center on centroid, PCA-orient, unit-RMS scale)
norm = navis.ml.normalize_neuron(n)

The transform is affine, so it is applied faithfully to everything the neuron carries - nodes/vertices, connectors, dotprops tangent vectors, radii and the .units - via navis.xform. The result is a copy; the input is never modified.

Let's check the two defining invariants: the normalized neuron is centered on the origin and has unit root-mean-square radius.

co = norm.nodes[["x", "y", "z"]].values
print("centroid :", co.mean(axis=0).round(6))          # ~ (0, 0, 0)
print("RMS radius:", round(float(np.sqrt((co ** 2).sum(axis=1).mean())), 6))  # ~ 1.0

Out:

centroid : [-0. -0.  0.]
RMS radius: 1.0

Note also how the units were rescaled to keep the physical size meaningful:

print("raw  units:", n.units)
print("norm units:", norm.units)

Out:

raw  units: 8 nanometer
norm units: 60.02434368177487 micrometer

Let's visualize the raw vs. normalized neuron. Note the very different axis scales - nanometers on the left, dimensionless canonical units on the right - and that the normalized neuron is centered on the origin with its largest-variance axis along x.

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

navis.plot2d(n, method="2d", view=("x", "-z"), ax=axes[0], color="k", lw=0.6)
navis.plot2d(norm, method="2d", view=("x", "-z"), ax=axes[1], color="C0", lw=0.6)

for ax in axes:
    ax.axhline(0, color="lightgrey", lw=0.5, zorder=0)
    ax.axvline(0, color="lightgrey", lw=0.5, zorder=0)
axes[0].set_title("Raw (nanometers)")
axes[1].set_title("Normalized (canonical frame)")
plt.tight_layout()

Raw (nanometers), Normalized (canonical frame)

Pose invariance#

The whole point is that the canonical frame depends only on the shape, not on how the neuron happened to come in. Let's prove it: take the neuron, apply an arbitrary rotation and translation, then normalize both the original and the moved copy. They should land on top of each other.

# Build an arbitrary rigid transform (rotate ~50 deg about z, then translate)
theta = np.deg2rad(50)
M = np.eye(4)
M[:3, :3] = [
    [np.cos(theta), -np.sin(theta), 0],
    [np.sin(theta), np.cos(theta), 0],
    [0, 0, 1],
]
M[:3, 3] = [50_000, -30_000, 10_000]
moved = navis.xform(n, navis.transforms.AffineTransform(M))

# Normalize both and overlay
norm_a = navis.ml.normalize_neuron(n)
norm_b = navis.ml.normalize_neuron(moved)

fig, ax = plt.subplots(figsize=(6, 6))
navis.plot2d(norm_a, method="2d", view=("x", "-z"), ax=ax, color="C0", lw=1.5)
navis.plot2d(norm_b, method="2d", view=("x", "-z"), ax=ax, color="C1", lw=0.8, ls="--")
ax.set_title("Original (solid) vs. moved-then-normalized (dashed)")
plt.tight_layout()

Original (solid) vs. moved-then-normalized (dashed)

The two canonical poses are identical - the arbitrary rotation and translation were removed.

How PCA orientation is disambiguated

PCA orientation is disambiguated deterministically (the same shape always lands the same way up) and handedness is preserved - neurons are never mirrored, because morphology is chiral and a reflection would be a different shape. Orientation is unstable when two principal axes have near-equal spread (a neuron with no dominant axis).

The knobs#

Each of the three steps is configurable and can be turned off independently by passing None:

Parameter Options Default Effect
center "centroid", "bbox" (bounding-box midpoint), "soma", an explicit (x, y, z), None "centroid" moves the reference point to the origin
rotate "pca", None "pca" aligns the arbor's principal axes with x/y/z
scale "rms" (unit RMS radius), "extent" (longest axis becomes 1), "max" (farthest point becomes 1), None "rms" sets the overall size convention

center picks the reference point moved to the origin - here the soma, with rotation and scaling switched off:

soma_centered = navis.ml.normalize_neuron(n, center="soma", rotate=None, scale=None)
sid = np.atleast_1d(n.soma)
soma_co = soma_centered.nodes.set_index("node_id").loc[sid, ["x", "y", "z"]].values
print("soma at origin:", np.allclose(soma_co.mean(axis=0), 0, atol=1e-5))

Out:

soma at origin: True

scale sets the size convention. The three options differ in what they hold fixed - "rms" is robust to a few far-flung nodes, while "max" gives a unit enclosing sphere:

for scale in ("rms", "extent", "max"):
    c = navis.ml.normalize_neuron(n, scale=scale).nodes[["x", "y", "z"]].values
    print(
        f"{scale:>7}: RMS={np.sqrt((c ** 2).sum(1).mean()):.3f}  "
        f"max_extent={(c.max(0) - c.min(0)).max():.3f}  "
        f"max_radius={np.sqrt((c ** 2).sum(1)).max():.3f}"
    )

Out:

    rms: RMS=1.000  max_extent=3.851  max_radius=3.254
 extent: RMS=0.260  max_extent=1.000  max_radius=0.845
    max: RMS=0.307  max_extent=1.183  max_radius=1.000

rotate is either "pca" (default) or None. Set scale=None to leave sizes (and radii and .units) untouched; set rotate=None to keep the incoming orientation. Passing center=None, rotate=None, scale=None is a no-op copy.

Mapping model outputs back#

Because normalization is a single matrix it is exactly invertible. Pass return_matrix=True to get the 4x4 homogeneous matrix - handy when a model predicts something in the normalized frame (a landmark, a direction, a denoised position) that you need back in the neuron's original coordinates.

norm, T = navis.ml.normalize_neuron(n, return_matrix=True)

# `T` maps original -> normalized; its inverse maps back.
pt_norm = norm.nodes[["x", "y", "z"]].values[0]             # a point in the model frame
pt_orig = (np.linalg.inv(T) @ np.append(pt_norm, 1))[:3]    # back to nanometers

print("recovered:", pt_orig.round(2))
print("original :", n.nodes[["x", "y", "z"]].values[0].round(2))

Out:

recovered: [15784. 37250. 28062.]
original : [15784. 37250. 28062.]

Whole populations#

A navis.NeuronList is normalized element-wise - each neuron gets its own canonical frame, so a whole population lands in a shared, comparable coordinate system:

nl = navis.example_neurons(3, kind="skeleton")
nl_norm = navis.ml.normalize_neuron(nl)

fig, ax = plt.subplots(figsize=(6, 6))
navis.plot2d(nl_norm, method="2d", view=("x", "-z"), ax=ax, lw=0.6)
ax.set_title("3 neurons, each normalized into the canonical frame")
plt.tight_layout()

3 neurons, each normalized into the canonical frame

Tip

normalize_neuron also works on navis.MeshNeuron and navis.Dotprops (whose tangent vectors are rotated along with the points). VoxelNeurons are not supported - rotating a dense grid needs resampling; convert to points or a mesh first.

What's next#

A normalized neuron is still a variable-sized graph. The next tutorial turns it into a fixed-size model input:

See the Machine Learning API reference for the full list of functions.

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

Download Python source code: tutorial_ml_00_normalize.py

Download Jupyter notebook: tutorial_ml_00_normalize.ipynb

Gallery generated by mkdocs-gallery