Skip to content

Working with Annotations

Annotations represent discrete biological features at specific locations — most commonly synapses, but also spines, branch labels, or manual markers. Unlike the main morphological layers (mesh, graph, skeleton), annotations are sparse point clouds: they don't define the neuron's shape, but they carry critical information about it. A typical neuron might have thousands of synapse annotations, each with metadata like type, size, and partner identity.

Shared Features

Annotations inherit many common features from the PointMixin class. For information about features, masking, transformations, spatial queries, and cross-layer mapping, see Shared Layer Features.

Annotations vs Layer features

Annotations are sparse point clouds representing discrete features. Layer features are arrays of values attached to every vertex in a layer. Use annotations for sparse features (synapses, spines) and features for properties of existing vertices (radius, compartment).

What are Annotations?

Annotations (PointCloudLayer in annotation context) contain: - Vertices: 3D coordinates of feature locations - features: Metadata about each annotation (confidence, type, size, etc.) - Linkage: Optional connections to morphological layers - Sparse nature: Represent discrete events, not continuous morphology

Setup

The snippets below use a small cell with a skeleton and a linked set of synapse annotations:

import numpy as np
import ossify
from ossify import Link

cell = ossify.Cell(name="annotated_cell")
cell.add_skeleton(
    vertices=np.array([[0.0, 0, 0], [1, 0, 0], [2, 0, 0]]),
    edges=np.array([[0, 1], [1, 2]]),
    root=0,
)
cell.add_point_annotations(
    name="synapses",
    vertices=np.array([[0.5, 0.1, 0.0], [1.5, -0.1, 0.0], [0.8, 0.2, 0.0]]),
    spatial_columns=["x", "y", "z"],
    linkage=Link(mapping=np.array([0, 1, 0]), target="skeleton"),
)

Inspecting Annotations

Quick Overview with describe()

The describe() method provides a comprehensive summary of annotation layers, showing vertex counts, features, and connections to other layers:

# Individual annotation layer
cell.annotations.synapses.describe()

Output:

# Cell: my_neuron
# Layer: synapses (PointCloudLayer)
├── 23 vertices
├── features: [synapse_type, confidence, size]
└── Links: skeleton → synapses

The output shows: - Cell context: Which cell these annotations belong to - Layer type: Confirms this is a PointCloudLayer (annotation) - Metrics: Vertex count (number of annotation points) - features: Available metadata columns for each annotation - Links: Connections to morphological layers (<-> = bidirectional, → = unidirectional)

Annotation Manager Overview

You can inspect all annotations at once:

# All annotations in the cell  
cell.annotations.describe()

Output:

# Annotations (2)
├── synapses (PointCloudLayer)
│   ├── 23 vertices
│   ├── features: [synapse_type, confidence]
│   └── Links: skeleton → synapses
└── spines (PointCloudLayer)
    ├── 47 vertices
    ├── features: [spine_type, head_diameter]
    └── Links: skeleton → spines

This gives you a detailed overview of all annotation layers and their relationships to morphological layers.

Creating Annotations

Basic Point Annotations

import numpy as np
import ossify

# Create a cell with a skeleton
cell = ossify.Cell(name="annotated_cell")
skeleton_vertices = np.array([[0,0,0], [1,0,0], [2,0,0]])
skeleton_edges = np.array([[0,1], [1,2]])
cell.add_skeleton(vertices=skeleton_vertices, edges=skeleton_edges, root=0)

# Add synaptic sites as annotations
synapse_locations = np.array([
    [0.5, 0.1, 0.0],   # Near first edge
    [1.5, -0.1, 0.0],  # Near second edge
    [0.8, 0.2, 0.0],   # Between vertices
])

# `linkage` records which skeleton vertex each synapse sits on. Without it the
# annotation cannot be mapped to the skeleton or follow a mask.
cell.add_point_annotations(
    name="synapses",
    vertices=synapse_locations,
    spatial_columns=["x", "y", "z"],
    linkage=Link(mapping=np.array([0, 1, 0]), target="skeleton"),
)

print(f"Added {len(cell.annotations.synapses.vertices)} synapses")
print(f"Available annotations: {cell.annotations.names}")

Annotations with Metadata

import pandas as pd

# Create annotation data with metadata
synapse_data = pd.DataFrame({
    'x': [0.5, 1.5, 0.8],
    'y': [0.1, -0.1, 0.2], 
    'z': [0.0, 0.0, 0.0],
    'confidence': [0.95, 0.87, 0.92],
    'synapse_type': ['excitatory', 'inhibitory', 'excitatory'],
    'size': [1.2, 0.8, 1.0],
    'partner_id': [12345, 67890, 11111]
})

cell.add_point_annotations(
    name="synapses_detailed",
    vertices=synapse_data,
    spatial_columns=['x', 'y', 'z'],
    linkage=Link(mapping=np.array([0, 1, 0]), target="skeleton"),
)

# Access annotation metadata
synapses = cell.annotations.synapses_detailed
print(f"Confidence values: {synapses.get_feature('confidence')}")
print(f"Synapse types: {synapses.get_feature('synapse_type')}")

Multiple Annotation Types

# Add different types of annotations
# Presynaptic sites
pre_syn_locations = np.array([[0.2, 0.1, 0.0], [1.8, -0.1, 0.0]])
cell.add_point_annotations(
    name="pre_syn",
    vertices=pre_syn_locations,
    features={"strength": [0.8, 0.6]},
    linkage=Link(mapping=np.array([0, 2]), target="skeleton"),
)

# Postsynaptic sites  
post_syn_locations = np.array([[0.7, 0.2, 0.0], [1.3, 0.1, 0.0]])
cell.add_point_annotations(
    name="post_syn",
    vertices=post_syn_locations,
    features={"receptor_type": ["AMPA", "GABA"]},
    linkage=Link(mapping=np.array([0, 1]), target="skeleton"),
)

# Dendritic spines
spine_locations = np.array([[0.3, 0.3, 0.0], [0.9, -0.2, 0.0], [1.7, 0.3, 0.0]])
cell.add_point_annotations(
    name="spines",
    vertices=spine_locations,
    features={
        "spine_type": ["mushroom", "thin", "stubby"],
        "volume": [0.05, 0.02, 0.03]
    },
    linkage=Link(mapping=np.array([0, 0, 1]), target="skeleton"),
)

print(f"All annotations: {cell.annotations.names}")

Linking Annotations to Morphological Layers

Manual Linkage Specification

from ossify import Link

# Create annotations with explicit linkage to skeleton
# Link by providing skeleton vertex IDs that annotations map to
mapping_to_skeleton = np.array([0, 1, 0])  # Synapses map to skeleton vertices 0, 1, 0

cell.add_point_annotations(
    name="linked_synapses",
    vertices=synapse_locations,
    linkage=Link(
        mapping=mapping_to_skeleton,
        target="skeleton",           # Target layer
        map_value_is_index=True     # Values are vertex indices
    ),
    features={"confidence": [0.9, 0.8, 0.7]}
)

Linking by Proximity

There is no automatic distance-based linkage: a Link carries an explicit mapping. To link by proximity, find the nearest target vertex yourself with the layer's KD-tree and pass the result as the mapping.

# Nearest skeleton vertex for each annotation
_, nearest = cell.skeleton.kdtree.query(synapse_locations)

cell.add_point_annotations(
    name="auto_linked_synapses",
    vertices=synapse_locations,
    spatial_columns=["x", "y", "z"],
    linkage=Link(mapping=nearest, target="skeleton"),
)

# Snap the annotation coordinates onto the linked skeleton vertices by letting
# the link supply them. `vertices` must then be a DataFrame of features only.
import pandas as pd

cell.add_point_annotations(
    name="snapped_synapses",
    vertices=pd.DataFrame({"confidence": [0.9, 0.8, 0.7]}),
    linkage=Link(
        mapping=cell.skeleton.vertex_index[nearest],
        target="skeleton",
        map_value_is_index=True,
    ),
    vertices_from_linkage=True,
)

Working with Annotation Data

Accessing Annotation Properties

synapses = cell.annotations.synapses_detailed

# Basic properties
print(f"Number of annotations: {synapses.n_vertices}")
print(f"Spatial columns: {synapses.spatial_columns}")
print(f"Available features: {synapses.feature_names}")

# Coordinates and data
coordinates = synapses.vertices           # Nx3 coordinate array
full_data = synapses.nodes               # DataFrame with coordinates + features
features_only = synapses.features            # DataFrame with just features

# Individual feature access
confidence = synapses.get_feature('confidence')
types = synapses.get_feature('synapse_type')

Filtering and Queries

# Filter by feature values
high_confidence = synapses.get_feature('confidence') > 0.9
high_conf_annotations = synapses.apply_mask(
    high_confidence, as_positional=True, return_cell=False
)

print(f"High confidence annotations: {high_conf_annotations.n_vertices}")

# Filter by type
excitatory_mask = synapses.get_feature('synapse_type') == 'excitatory'
excitatory_synapses = synapses.apply_mask(
    excitatory_mask, as_positional=True, return_cell=False
)

# Spatial filtering using KDTree
query_point = [1.0, 0.0, 0.0]
distances, indices = synapses.kdtree.query(query_point, k=2, distance_upper_bound=0.5)

# Get nearby annotations (excluding infinite distances)
nearby_mask = distances < np.inf
nearby_indices = indices[nearby_mask]
nearby_annotations = synapses.apply_mask(
    nearby_indices, as_positional=True, return_cell=False
)

Annotation Statistics

# Summary statistics by type
types = synapses.get_feature('synapse_type')
confidence = synapses.get_feature('confidence')

for syn_type in np.unique(types):
    type_mask = types == syn_type
    type_confidence = confidence[type_mask]
    print(f"{syn_type}: count={sum(type_mask)}, mean_confidence={np.mean(type_confidence):.3f}")

# Size distribution
sizes = synapses.get_feature('size')
print(f"Size range: {np.min(sizes):.2f} - {np.max(sizes):.2f}")
print(f"Mean size: {np.mean(sizes):.2f}")

Mapping Annotations to Morphological Layers

Aggregating to Layer Vertices

# Count annotations near each skeleton vertex
if cell.skeleton is not None:
    synapse_counts = cell.skeleton.map_annotations_to_feature(
        annotation="synapses_detailed",
        distance_threshold=0.3,     # Search radius
        agg="count",                # Count annotations
        chunk_size=1000,
        validate=False
    )

    # Add as skeleton feature. With agg="count" this comes back as a
    # one-column DataFrame already named `synapses_count`, so passing `name`
    # here would just be ignored.
    cell.skeleton.add_feature(synapse_counts)

    # Aggregate by type
    type_counts = cell.skeleton.map_annotations_to_feature(
        annotation="synapses_detailed",
        distance_threshold=0.3,
        agg={
            "excitatory_count": ("synapse_type", lambda x: sum(x == 'excitatory')),
            "inhibitory_count": ("synapse_type", lambda x: sum(x == 'inhibitory')),
            "mean_confidence": ("confidence", "mean"),
            "total_strength": ("size", "sum")
        }
    )

    cell.skeleton.add_feature(type_counts)
    print(f"Skeleton now has features: {cell.skeleton.feature_names}")

Finding Annotations Linked to Specific Vertices

# Find annotations that map to specific skeleton vertices
target_vertices = cell.skeleton.vertex_index[:2]  # First two skeleton vertices

# Get annotations linked to these vertices
linked_annotation_indices = cell.annotations.synapses_detailed.map_index_to_layer(
    layer="skeleton",
    source_index=target_vertices,
    as_positional=False
)

print(f"Annotations linked to vertices {target_vertices}: {linked_annotation_indices}")

# Reverse mapping: find which skeleton vertex each annotation links to
skeleton_links = cell.annotations.synapses_detailed.map_index_to_layer(
    layer="skeleton",
    as_positional=False
)
print(f"Annotation-to-skeleton mapping: {skeleton_links}")

Annotation Management

Adding and Removing Annotations

# Add more annotations
additional_spines = np.array([[0.4, 0.4, 0.0], [1.6, -0.3, 0.0]])
cell.add_point_annotations(
    name="additional_spines",
    vertices=additional_spines,
    features={"spine_type": ["thin", "mushroom"]}
)

# Remove annotation layer
cell.remove_annotation("additional_spines")
print(f"Remaining annotations: {cell.annotations.names}")

# Clear all annotations. Do this on a copy, so the sections below still have
# the annotations created earlier.
scratch = cell.copy()
for name in list(scratch.annotations.names):
    scratch.remove_annotation(name)
print(f"Annotations after clearing: {scratch.annotations.names}")

Copying and Transforming Annotations

# Recreate some annotations for demonstration
cell.add_point_annotations("test_synapses", vertices=synapse_locations)

# Copy annotation layer
synapses_copy = cell.annotations.test_synapses.copy()

# Transform annotation coordinates
def shift_up(vertices):
    return vertices + [0, 0, 1.0]  # Move up by 1 unit

transformed_synapses = cell.annotations.test_synapses.transform(
    shift_up, 
    inplace=False
)
print(f"Original Z coords: {cell.annotations.test_synapses.vertices[:, 2]}")
print(f"Transformed Z coords: {transformed_synapses.vertices[:, 2]}")

Advanced Annotation Analysis

Density Analysis

# `synapses` was created and linked above; reuse it rather than re-adding it.
# Calculate annotation density along skeleton
if cell.skeleton is not None:
    # Density per unit length
    synapse_density = cell.skeleton.map_annotations_to_feature(
        annotation="synapses",
        distance_threshold=0.5,
        agg="density",              # Annotations per unit cable length
        chunk_size=1000
    )

    cell.skeleton.add_feature(synapse_density, name="synapse_density")

    # Total density
    total_cable = cell.skeleton.cable_length()
    total_synapses = len(cell.annotations.synapses.vertices)
    overall_density = total_synapses / total_cable
    print(f"Overall synapse density: {overall_density:.3f} synapses/unit")

Spatial Clustering

# Find clusters of annotations. scipy is already an ossify dependency; the
# same idea works with sklearn's DBSCAN if you have it.
from scipy.cluster.hierarchy import fcluster, linkage as hierarchical_linkage

coordinates = cell.annotations.synapses.vertices
cluster_features = fcluster(
    hierarchical_linkage(coordinates, method="single"),
    t=0.3,               # Merge points closer than this
    criterion="distance",
)

# Add cluster information
cell.annotations.synapses.add_feature(cluster_features, name="cluster")

n_clusters = len(np.unique(cluster_features))
print(f"Found {n_clusters} clusters")

Co-localization Analysis

# Analyze spatial relationship between different annotation types
if "pre_syn" in cell.annotations.names and "post_syn" in cell.annotations.names:
    pre_coords = cell.annotations.pre_syn.vertices
    post_coords = cell.annotations.post_syn.vertices

    # Find nearest post-synaptic site for each pre-synaptic site
    from scipy.spatial.distance import cdist
    distances = cdist(pre_coords, post_coords)
    nearest_post = np.argmin(distances, axis=1)
    min_distances = np.min(distances, axis=1)

    # Add co-localization info
    cell.annotations.pre_syn.add_feature(nearest_post, name="nearest_post_idx")
    cell.annotations.pre_syn.add_feature(min_distances, name="distance_to_post")

    print(f"Mean pre-post distance: {np.mean(min_distances):.3f}")

Key Annotation Methods

Annotation Creation

  • cell.add_point_annotations(name, vertices, spatial_columns=None, features=None, linkage=None, vertices_from_linkage=False) - Add annotation layer

Annotation Management

  • cell.remove_annotation(name) - Remove annotation layer
  • cell.annotations.names - List of annotation names
  • cell.annotations[name] - Access specific annotation

Annotation Properties

  • annotation.n_vertices - Number of annotations
  • annotation.vertices - Coordinate array
  • annotation.nodes - DataFrame with coordinates and features
  • annotation.features - DataFrame with just features
  • annotation.feature_names - Available feature names

Data Access and Analysis

  • annotation.get_feature(key) - Get feature values
  • annotation.add_feature(feature, name=None) - Add new features
  • annotation.apply_mask(mask, as_positional=False, return_cell=False) - Filter annotations
  • annotation.kdtree - Spatial queries

Cross-layer Integration

  • layer.map_annotations_to_feature(annotation, distance_threshold, agg="count"/"density", ...) - Aggregate to layer vertices
  • annotation.map_index_to_layer(layer, source_index=None, as_positional=False) - Find layer mappings

Linkage

  • Link(mapping, target, map_value_is_index=True) - Explicit linkage
  • Link(target, distance_threshold) - Automatic spatial linkage

Additional Features

For comprehensive information about vertex access, transformations, spatial queries, and other shared functionality, see Shared Layer Features.

When to Use Annotations

  • Sparse features like synapses, spines, or markers
  • Event locations that don't correspond to morphological vertices
  • Features with rich metadata (confidence, type, size, etc.)
  • Analysis requiring spatial aggregation to morphological layers
  • Co-localization analysis between different feature types