Skip to content

skeleton

sleap_io.model.skeleton

Data model for skeletons.

Skeletons are collections of nodes and edges which describe the landmarks associated with a pose model. The edges represent the connections between them and may be used differently depending on the underlying pose model.

Classes:

Name Description
Edge

A connection between two Node objects within a Skeleton.

Node

A landmark type within a Skeleton.

Skeleton

A description of a set of landmark types and connections between them.

Symmetry

A relationship between a pair of nodes denoting their left/right pairing.

Functions:

Name Description
infer_symmetry_pairs_by_name

Infer left/right symmetric node pairs from node names.

is_node_or_index

Check if an object is a Node, string name or integer index.

match_nodes_cached

Match nodes in two skeletons by name.

Attributes:

Name Type Description
DEFAULT_LR_TOKENS

Built-in mutable sequence.

__annotations__

dict() -> new empty dictionary

__cached__

str(object='') -> str

__doc__

str(object='') -> str

__file__

str(object='') -> str

__name__

str(object='') -> str

__package__

str(object='') -> str

DEFAULT_LR_TOKENS = [('left', 'right'), ('l', 'r')] module-attribute

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

__annotations__ = {'DEFAULT_LR_TOKENS': 'list[tuple[str, str]]'} module-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__cached__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/model/__pycache__/skeleton.cpython-313.pyc' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__doc__ = 'Data model for skeletons.\n\nSkeletons are collections of nodes and edges which describe the landmarks associated\nwith a pose model. The edges represent the connections between them and may be used\ndifferently depending on the underlying pose model.\n' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__file__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/model/skeleton.py' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__name__ = 'sleap_io.model.skeleton' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__package__ = 'sleap_io.model' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

Edge

A connection between two Node objects within a Skeleton.

This is a directed edge, representing the ordering of Nodes in the Skeleton tree.

Attributes:

Name Type Description
source

The origin Node.

destination

The destination Node.

Methods:

Name Description
__eq__

Method generated by attrs for class Edge.

__getitem__

Return the source Node (idx is 0) or destination Node (idx is 1).

__hash__

Method generated by attrs for class Edge.

__init__

Method generated by attrs for class Edge.

__repr__

Method generated by attrs for class Edge.

Source code in sleap_io/model/skeleton.py
@define(frozen=True)
class Edge:
    """A connection between two `Node` objects within a `Skeleton`.

    This is a directed edge, representing the ordering of `Node`s in the `Skeleton`
    tree.

    Attributes:
        source: The origin `Node`.
        destination: The destination `Node`.
    """

    source: Node
    destination: Node

    def __getitem__(self, idx) -> Node:
        """Return the source `Node` (`idx` is 0) or destination `Node` (`idx` is 1)."""
        if idx == 0:
            return self.source
        elif idx == 1:
            return self.destination
        else:
            raise IndexError("Edge only has 2 nodes (source and destination).")

__annotations__ = {'source': 'Node', 'destination': 'Node'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=True, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.HASHABLE: 'hashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=None, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'A connection between two `Node` objects within a `Skeleton`.\n\nThis is a directed edge, representing the ordering of `Node`s in the `Skeleton`\ntree.\n\nAttributes:\n source: The origin `Node`.\n destination: The destination `Node`.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 32 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('source', 'destination') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.skeleton' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('source', 'destination', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__eq__(other)

Method generated by attrs for class Edge.

Source code in sleap_io/model/skeleton.py
@define(eq=False)
class Node:
    """A landmark type within a `Skeleton`.

    This typically corresponds to a unique landmark within a skeleton, such as the "left

__getitem__(idx)

Return the source Node (idx is 0) or destination Node (idx is 1).

Source code in sleap_io/model/skeleton.py
def __getitem__(self, idx) -> Node:
    """Return the source `Node` (`idx` is 0) or destination `Node` (`idx` is 1)."""
    if idx == 0:
        return self.source
    elif idx == 1:
        return self.destination
    else:
        raise IndexError("Edge only has 2 nodes (source and destination).")

__hash__()

Method generated by attrs for class Edge.

Source code in sleap_io/model/skeleton.py
eye".

Attributes:
    name: Descriptive label for the landmark.
"""

__init__(source, destination)

Method generated by attrs for class Edge.

Source code in sleap_io/model/skeleton.py
    name: str


@define(frozen=True)

__repr__()

Method generated by attrs for class Edge.

Source code in sleap_io/model/skeleton.py
"""Data model for skeletons.

Skeletons are collections of nodes and edges which describe the landmarks associated
with a pose model. The edges represent the connections between them and may be used
differently depending on the underlying pose model.
"""

from __future__ import annotations

import re
import typing
from functools import lru_cache

import numpy as np
from attrs import define, field

Node

A landmark type within a Skeleton.

This typically corresponds to a unique landmark within a skeleton, such as the "left eye".

Attributes:

Name Type Description
name

Descriptive label for the landmark.

Methods:

Name Description
__init__

Method generated by attrs for class Node.

__repr__

Method generated by attrs for class Node.

Source code in sleap_io/model/skeleton.py
@define(eq=False)
class Node:
    """A landmark type within a `Skeleton`.

    This typically corresponds to a unique landmark within a skeleton, such as the "left
    eye".

    Attributes:
        name: Descriptive label for the landmark.
    """

    name: str

__annotations__ = {'name': 'str'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = False class-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=False, added_ordering=False, hashability=<Hashability.LEAVE_ALONE: 'leave_alone'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'A landmark type within a `Skeleton`.\n\nThis typically corresponds to a unique landmark within a skeleton, such as the "left\neye".\n\nAttributes:\n name: Descriptive label for the landmark.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 18 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('name',) class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.skeleton' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('name', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__init__(name)

Method generated by attrs for class Node.

Source code in sleap_io/model/skeleton.py

__repr__()

Method generated by attrs for class Node.

Source code in sleap_io/model/skeleton.py
"""Data model for skeletons.

Skeletons are collections of nodes and edges which describe the landmarks associated
with a pose model. The edges represent the connections between them and may be used
differently depending on the underlying pose model.
"""

from __future__ import annotations

import re
import typing
from functools import lru_cache

import numpy as np
from attrs import define, field

Skeleton

A description of a set of landmark types and connections between them.

Skeletons are represented by a directed graph composed of a set of Nodes (landmark types such as body parts) and Edges (connections between parts).

Attributes:

Name Type Description
nodes

A list of Nodes. May be specified as a list of strings to create new nodes from their names.

edges

A list of Edges. May be specified as a list of 2-tuples of string names or integer indices of nodes. Each edge corresponds to a pair of source and destination nodes forming a directed edge.

symmetries

A list of Symmetrys. Each symmetry corresponds to symmetric body parts, such as "left eye", "right eye". This is used when applying flip (reflection) augmentation to images in order to appropriately swap the indices of symmetric landmarks.

name

A descriptive name for the Skeleton.

Methods:

Name Description
__attrs_post_init__

Ensure nodes are Nodes, edges are Edges, and Node map is updated.

__contains__

Check if a node is in the skeleton.

__getitem__

Return a Node when indexing by name or integer.

__init__

Method generated by attrs for class Skeleton.

__len__

Return the number of nodes in the skeleton.

__repr__

Return a readable representation of the skeleton.

__setattr__

Method generated by attrs for class Skeleton.

add_edge

Add an Edge to the skeleton.

add_edges

Add multiple Edges to the skeleton.

add_node

Add a Node to the skeleton.

add_nodes

Add multiple Nodes to the skeleton.

add_symmetries

Add multiple Symmetry relationships to the skeleton.

add_symmetry

Add a symmetry relationship to the skeleton.

get_flipped_node_inds

Returns node indices that should be switched when horizontally flipping.

index

Return the index of a node specified as a Node or string name.

infer_symmetries_by_name

Infer left/right symmetric node pairs from node names.

match_nodes

Return the order of nodes in the skeleton.

matches

Check if this skeleton matches another skeleton's structure.

node_similarities

Calculate node overlap metrics with another skeleton.

rebuild_cache

Rebuild the node name/index to Node map caches.

remove_node

Remove a single node from the skeleton.

remove_nodes

Remove nodes from the skeleton.

rename_node

Rename a single node in the skeleton.

rename_nodes

Rename nodes in the skeleton.

reorder_nodes

Reorder nodes in the skeleton.

require_node

Return a Node object, handling indexing and adding missing nodes.

Source code in sleap_io/model/skeleton.py
@define(eq=False)
class Skeleton:
    """A description of a set of landmark types and connections between them.

    Skeletons are represented by a directed graph composed of a set of `Node`s (landmark
    types such as body parts) and `Edge`s (connections between parts).

    Attributes:
        nodes: A list of `Node`s. May be specified as a list of strings to create new
            nodes from their names.
        edges: A list of `Edge`s. May be specified as a list of 2-tuples of string names
            or integer indices of `nodes`. Each edge corresponds to a pair of source and
            destination nodes forming a directed edge.
        symmetries: A list of `Symmetry`s. Each symmetry corresponds to symmetric body
            parts, such as `"left eye", "right eye"`. This is used when applying flip
            (reflection) augmentation to images in order to appropriately swap the
            indices of symmetric landmarks.
        name: A descriptive name for the `Skeleton`.
    """

    def _nodes_on_setattr(self, attr, new_nodes):
        """Callback to update caches when nodes are set."""
        self.rebuild_cache(nodes=new_nodes)
        return new_nodes

    nodes: list[Node] = field(
        factory=list,
        on_setattr=_nodes_on_setattr,
    )
    edges: list[Edge] = field(factory=list)
    symmetries: list[Symmetry] = field(factory=list)
    name: str | None = None
    _name_to_node_cache: dict[str, Node] = field(init=False, repr=False, eq=False)
    _node_to_ind_cache: dict[Node, int] = field(init=False, repr=False, eq=False)

    def __attrs_post_init__(self):
        """Ensure nodes are `Node`s, edges are `Edge`s, and `Node` map is updated."""
        self._convert_nodes()
        self._convert_edges()
        self._convert_symmetries()
        self.rebuild_cache()

    def _convert_nodes(self):
        """Convert nodes to `Node` objects if needed."""
        if isinstance(self.nodes, np.ndarray):
            object.__setattr__(self, "nodes", self.nodes.tolist())
        for i, node in enumerate(self.nodes):
            if type(node) is str:
                self.nodes[i] = Node(node)

    def _convert_edges(self):
        """Convert list of edge names or integers to `Edge` objects if needed."""
        if isinstance(self.edges, np.ndarray):
            self.edges = self.edges.tolist()
        node_names = self.node_names
        for i, edge in enumerate(self.edges):
            if type(edge) is Edge:
                continue
            src, dst = edge
            if type(src) is str:
                try:
                    src = node_names.index(src)
                except ValueError:
                    raise ValueError(
                        f"Node '{src}' specified in the edge list is not in the nodes."
                    )
            if type(src) is int or (
                np.isscalar(src) and np.issubdtype(src.dtype, np.integer)
            ):
                src = self.nodes[src]

            if type(dst) is str:
                try:
                    dst = node_names.index(dst)
                except ValueError:
                    raise ValueError(
                        f"Node '{dst}' specified in the edge list is not in the nodes."
                    )
            if type(dst) is int or (
                np.isscalar(dst) and np.issubdtype(dst.dtype, np.integer)
            ):
                dst = self.nodes[dst]

            self.edges[i] = Edge(src, dst)

    def _convert_symmetries(self):
        """Convert list of symmetric node names or integers to `Symmetry` objects."""
        if isinstance(self.symmetries, np.ndarray):
            self.symmetries = self.symmetries.tolist()

        node_names = self.node_names
        for i, symmetry in enumerate(self.symmetries):
            if type(symmetry) is Symmetry:
                continue
            node1, node2 = symmetry
            if type(node1) is str:
                try:
                    node1 = node_names.index(node1)
                except ValueError:
                    raise ValueError(
                        f"Node '{node1}' specified in the symmetry list is not in the "
                        "nodes."
                    )
            if type(node1) is int or (
                np.isscalar(node1) and np.issubdtype(node1.dtype, np.integer)
            ):
                node1 = self.nodes[node1]

            if type(node2) is str:
                try:
                    node2 = node_names.index(node2)
                except ValueError:
                    raise ValueError(
                        f"Node '{node2}' specified in the symmetry list is not in the "
                        "nodes."
                    )
            if type(node2) is int or (
                np.isscalar(node2) and np.issubdtype(node2.dtype, np.integer)
            ):
                node2 = self.nodes[node2]

            self.symmetries[i] = Symmetry({node1, node2})

    def rebuild_cache(self, nodes: list[Node] | None = None):
        """Rebuild the node name/index to `Node` map caches.

        Args:
            nodes: A list of `Node` objects to update the cache with. If not provided,
                the cache will be updated with the current nodes in the skeleton. If
                nodes are provided, the cache will be updated with the provided nodes,
                but the current nodes in the skeleton will not be updated. Default is
                `None`.

        Notes:
            This function should be called when nodes or node list is mutated to update
            the lookup caches for indexing nodes by name or `Node` object.

            This is done automatically when nodes are added or removed from the skeleton
            using the convenience methods in this class.

            This method only needs to be used when manually mutating nodes or the node
            list directly.
        """
        if nodes is None:
            nodes = self.nodes
        self._name_to_node_cache = {node.name: node for node in nodes}
        self._node_to_ind_cache = {node: i for i, node in enumerate(nodes)}

    @property
    def node_names(self) -> list[str]:
        """Names of the nodes associated with this skeleton as a list of strings."""
        return [node.name for node in self.nodes]

    @property
    def edge_inds(self) -> list[tuple[int, int]]:
        """Edges indices as a list of 2-tuples."""
        return [
            (self.nodes.index(edge.source), self.nodes.index(edge.destination))
            for edge in self.edges
        ]

    @property
    def edge_names(self) -> list[str, str]:
        """Edge names as a list of 2-tuples with string node names."""
        return [(edge.source.name, edge.destination.name) for edge in self.edges]

    @property
    def symmetry_inds(self) -> list[tuple[int, int]]:
        """Symmetry indices as a list of 2-tuples."""
        return [
            tuple(sorted((self.index(symmetry[0]), self.index(symmetry[1]))))
            for symmetry in self.symmetries
        ]

    @property
    def symmetry_names(self) -> list[str, str]:
        """Symmetry names as a list of 2-tuples with string node names."""
        return [
            (self.nodes[i].name, self.nodes[j].name) for (i, j) in self.symmetry_inds
        ]

    def get_flipped_node_inds(self) -> list[int]:
        """Returns node indices that should be switched when horizontally flipping.

        This is useful as a lookup table for flipping the landmark coordinates when
        doing data augmentation.

        Example:
            >>> skel = Skeleton(["A", "B_left", "B_right", "C", "D_left", "D_right"])
            >>> skel.add_symmetry("B_left", "B_right")
            >>> skel.add_symmetry("D_left", "D_right")
            >>> skel.flipped_node_inds
            [0, 2, 1, 3, 5, 4]
            >>> pose = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5]])
            >>> pose[skel.flipped_node_inds]
            array([[0, 0],
                   [2, 2],
                   [1, 1],
                   [3, 3],
                   [5, 5],
                   [4, 4]])
        """
        flip_idx = np.arange(len(self.nodes))
        if len(self.symmetries) > 0:
            symmetry_inds = np.array(
                [(self.index(a), self.index(b)) for a, b in self.symmetries]
            )
            flip_idx[symmetry_inds[:, 0]] = symmetry_inds[:, 1]
            flip_idx[symmetry_inds[:, 1]] = symmetry_inds[:, 0]

        flip_idx = flip_idx.tolist()
        return flip_idx

    def __len__(self) -> int:
        """Return the number of nodes in the skeleton."""
        return len(self.nodes)

    def __repr__(self) -> str:
        """Return a readable representation of the skeleton."""
        nodes = ", ".join([f'"{node}"' for node in self.node_names])
        return f"Skeleton(nodes=[{nodes}], edges={self.edge_inds})"

    def index(self, node: Node | str) -> int:
        """Return the index of a node specified as a `Node` or string name."""
        if type(node) is str:
            return self.index(self._name_to_node_cache[node])
        elif type(node) is Node:
            return self._node_to_ind_cache[node]
        else:
            raise IndexError(f"Invalid indexing argument for skeleton: {node}")

    def __getitem__(self, idx: NodeOrIndex) -> Node:
        """Return a `Node` when indexing by name or integer."""
        if type(idx) is int:
            return self.nodes[idx]
        elif type(idx) is str:
            return self._name_to_node_cache[idx]
        else:
            raise IndexError(f"Invalid indexing argument for skeleton: {idx}")

    def __contains__(self, node: NodeOrIndex) -> bool:
        """Check if a node is in the skeleton."""
        if type(node) is str:
            return node in self._name_to_node_cache
        elif type(node) is Node:
            return node in self.nodes
        elif type(node) is int:
            return 0 <= node < len(self.nodes)
        else:
            raise ValueError(f"Invalid node type for skeleton: {node}")

    def add_node(self, node: Node | str):
        """Add a `Node` to the skeleton.

        Args:
            node: A `Node` object or a string name to create a new node.

        Raises:
            ValueError: If the node already exists in the skeleton or if the node is
                not specified as a `Node` or string.
        """
        if node in self:
            raise ValueError(f"Node '{node}' already exists in the skeleton.")

        if type(node) is str:
            node = Node(node)

        if type(node) is not Node:
            raise ValueError(f"Invalid node type: {node} ({type(node)})")

        self.nodes.append(node)

        # Atomic update of the cache.
        self._name_to_node_cache[node.name] = node
        self._node_to_ind_cache[node] = len(self.nodes) - 1

    def add_nodes(self, nodes: list[Node | str]):
        """Add multiple `Node`s to the skeleton.

        Args:
            nodes: A list of `Node` objects or string names to create new nodes.
        """
        for node in nodes:
            self.add_node(node)

    def require_node(self, node: NodeOrIndex, add_missing: bool = True) -> Node:
        """Return a `Node` object, handling indexing and adding missing nodes.

        Args:
            node: A `Node` object, name or index.
            add_missing: If `True`, missing nodes will be added to the skeleton. If
                `False`, an error will be raised if the node is not found. Default is
                `True`.

        Returns:
            The `Node` object.

        Raises:
            IndexError: If the node is not found in the skeleton and `add_missing` is
                `False`.
        """
        if node not in self:
            if add_missing:
                self.add_node(node)
            else:
                raise IndexError(f"Node '{node}' not found in the skeleton.")

        if type(node) is Node:
            return node

        return self[node]

    def add_edge(
        self,
        src: NodeOrIndex | Edge | tuple[NodeOrIndex, NodeOrIndex],
        dst: NodeOrIndex | None = None,
    ):
        """Add an `Edge` to the skeleton.

        Args:
            src: The source node specified as a `Node`, name or index.
            dst: The destination node specified as a `Node`, name or index.
        """
        edge = None
        if type(src) is tuple:
            src, dst = src

        if is_node_or_index(src):
            if not is_node_or_index(dst):
                raise ValueError("Destination node must be specified.")

            src = self.require_node(src)
            dst = self.require_node(dst)
            edge = Edge(src, dst)

        if type(src) is Edge:
            edge = src

        if edge not in self.edges:
            self.edges.append(edge)

    def add_edges(self, edges: list[Edge | tuple[NodeOrIndex, NodeOrIndex]]):
        """Add multiple `Edge`s to the skeleton.

        Args:
            edges: A list of `Edge` objects or 2-tuples of source and destination nodes.
        """
        for edge in edges:
            self.add_edge(edge)

    def add_symmetry(
        self, node1: Symmetry | NodeOrIndex = None, node2: NodeOrIndex | None = None
    ):
        """Add a symmetry relationship to the skeleton.

        Args:
            node1: The first node specified as a `Node`, name or index. If a `Symmetry`
                object is provided, it will be added directly to the skeleton.
            node2: The second node specified as a `Node`, name or index.
        """
        symmetry = None
        if type(node1) is Symmetry:
            symmetry = node1
            node1, node2 = symmetry

        node1 = self.require_node(node1)
        node2 = self.require_node(node2)

        if symmetry is None:
            symmetry = Symmetry({node1, node2})

        if symmetry not in self.symmetries:
            self.symmetries.append(symmetry)

    def add_symmetries(
        self, symmetries: list[Symmetry | tuple[NodeOrIndex, NodeOrIndex]]
    ):
        """Add multiple `Symmetry` relationships to the skeleton.

        Args:
            symmetries: A list of `Symmetry` objects or 2-tuples of symmetric nodes.
        """
        for symmetry in symmetries:
            self.add_symmetry(*symmetry)

    def infer_symmetries_by_name(
        self,
        token_pairs: list[tuple[str, str]] | None = None,
    ) -> list[tuple[int, int]]:
        """Infer left/right symmetric node pairs from node names.

        Useful when a skeleton has no symmetries defined (e.g. imported from a
        format that does not carry symmetry metadata) but its node names encode
        laterality, so that flip-dependent tooling (augmentation, QC) still
        works. Names are matched by splitting on separators (`_`, `-`, `.`,
        space), camelCase boundaries, and letter/digit boundaries, then pairing
        nodes that share a stem but differ by a single left/right token. For
        example, `Ear_L`/`Ear_R`, `left_eye`/`right_eye`, `LeftPaw`/`RightPaw`,
        and `L1`/`R1` all pair up.

        This is intentionally **non-mutating** and conservative: it returns
        suggested pairs rather than writing them onto the skeleton, since a wrong
        guess would silently corrupt flip augmentation. Apply the result
        explicitly if desired, e.g.
        `skel.add_symmetries(skel.infer_symmetries_by_name())`. Node names
        without a delimited or camelCase/digit token boundary (e.g. `larm`) and
        truly non-semantic pairings (e.g. `L1`/`L2`) cannot be inferred and must
        be declared with `add_symmetry`.

        Args:
            token_pairs: List of `(left_token, right_token)` string pairs used to
                recognize laterality, matched case-insensitively against whole
                name segments. Defaults to `[("left", "right"), ("l", "r")]`.

        Returns:
            A list of `(left_index, right_index)` node-index pairs, ordered by
            left index. Each node appears in at most one pair, and only stems
            with exactly one left and one right member are paired (ambiguous
            groups are skipped).

        Example:
            >>> skel = Skeleton(["nose", "eye_L", "eye_R", "ear_L", "ear_R"])
            >>> skel.infer_symmetries_by_name()
            [(1, 2), (3, 4)]
            >>> skel.add_symmetries(skel.infer_symmetries_by_name())
            >>> skel.symmetry_names
            [('eye_L', 'eye_R'), ('ear_L', 'ear_R')]
        """
        return infer_symmetry_pairs_by_name(self.node_names, token_pairs=token_pairs)

    def rename_nodes(self, name_map: dict[NodeOrIndex, str] | list[str]):
        """Rename nodes in the skeleton.

        Args:
            name_map: A dictionary mapping old node names to new node names. Keys can be
                specified as `Node` objects, integer indices, or string names. Values
                must be specified as string names.

                If a list of strings is provided of the same length as the current
                nodes, the nodes will be renamed to the names in the list in order.

        Raises:
            ValueError: If the new node names exist in the skeleton or if the old node
                names are not found in the skeleton.

        Notes:
            This method should always be used when renaming nodes in the skeleton as it
            handles updating the lookup caches necessary for indexing nodes by name.

            After renaming, instances using this skeleton **do NOT need to be updated**
            as the nodes are stored by reference in the skeleton, so changes are
            reflected automatically.

        Example:
            >>> skel = Skeleton(["A", "B", "C"], edges=[("A", "B"), ("B", "C")])
            >>> skel.rename_nodes({"A": "X", "B": "Y", "C": "Z"})
            >>> skel.node_names
            ["X", "Y", "Z"]
            >>> skel.rename_nodes(["a", "b", "c"])
            >>> skel.node_names
            ["a", "b", "c"]
        """
        if type(name_map) is list:
            if len(name_map) != len(self.nodes):
                raise ValueError(
                    "List of new node names must be the same length as the current "
                    "nodes."
                )
            name_map = {node: name for node, name in zip(self.nodes, name_map)}

        for old_name, new_name in name_map.items():
            if type(old_name) is Node:
                old_name = old_name.name
            if type(old_name) is int:
                old_name = self.nodes[old_name].name

            if old_name not in self._name_to_node_cache:
                raise ValueError(f"Node '{old_name}' not found in the skeleton.")
            if new_name in self._name_to_node_cache:
                raise ValueError(f"Node '{new_name}' already exists in the skeleton.")

            node = self._name_to_node_cache[old_name]
            node.name = new_name
            self._name_to_node_cache[new_name] = node
            del self._name_to_node_cache[old_name]

    def rename_node(self, old_name: NodeOrIndex, new_name: str):
        """Rename a single node in the skeleton.

        Args:
            old_name: The name of the node to rename. Can also be specified as an
                integer index or `Node` object.
            new_name: The new name for the node.
        """
        self.rename_nodes({old_name: new_name})

    def remove_nodes(self, nodes: list[NodeOrIndex]):
        """Remove nodes from the skeleton.

        Args:
            nodes: A list of node names, indices, or `Node` objects to remove.

        Notes:
            This method handles updating the lookup caches necessary for indexing nodes
            by name.

            Any edges and symmetries that are connected to the removed nodes will also
            be removed.

        Warning:
            **This method does NOT update instances** that use this skeleton to reflect
            changes.

            It is recommended to use the `Labels.remove_nodes()` method which will
            update all contained to reflect the changes made to the skeleton.

            To manually update instances after this method is called, call
            `instance.update_nodes()` on each instance that uses this skeleton.
        """
        # Standardize input and make a pre-mutation copy before keys are changed.
        rm_node_objs = [self.require_node(node, add_missing=False) for node in nodes]

        # Remove nodes from the skeleton.
        for node in rm_node_objs:
            self.nodes.remove(node)
            del self._name_to_node_cache[node.name]

        # Remove edges connected to the removed nodes.
        self.edges = [
            edge
            for edge in self.edges
            if edge.source not in rm_node_objs and edge.destination not in rm_node_objs
        ]

        # Remove symmetries connected to the removed nodes.
        self.symmetries = [
            symmetry
            for symmetry in self.symmetries
            if symmetry.nodes.isdisjoint(rm_node_objs)
        ]

        # Update node index map.
        self.rebuild_cache()

    def remove_node(self, node: NodeOrIndex):
        """Remove a single node from the skeleton.

        Args:
            node: The node to remove. Can be specified as a string name, integer index,
                or `Node` object.

        Notes:
            This method handles updating the lookup caches necessary for indexing nodes
            by name.

            Any edges and symmetries that are connected to the removed node will also be
            removed.

        Warning:
            **This method does NOT update instances** that use this skeleton to reflect
            changes.

            It is recommended to use the `Labels.remove_nodes()` method which will
            update all contained instances to reflect the changes made to the skeleton.

            To manually update instances after this method is called, call
            `Instance.update_skeleton()` on each instance that uses this skeleton.
        """
        self.remove_nodes([node])

    def reorder_nodes(self, new_order: list[NodeOrIndex]):
        """Reorder nodes in the skeleton.

        Args:
            new_order: A list of node names, indices, or `Node` objects specifying the
                new order of the nodes.

        Raises:
            ValueError: If the new order of nodes is not the same length as the current
                nodes.

        Notes:
            This method handles updating the lookup caches necessary for indexing nodes
            by name.

        Warning:
            After reordering, instances using this skeleton do not need to be updated as
            the nodes are stored by reference in the skeleton.

            However, the order that points are stored in the instances will not be
            updated to match the new order of the nodes in the skeleton. This should not
            matter unless the ordering of the keys in the `Instance.points` dictionary
            is used instead of relying on the skeleton node order.

            To make sure these are aligned, it is recommended to use the
            `Labels.reorder_nodes()` method which will update all contained instances to
            reflect the changes made to the skeleton.

            To manually update instances after this method is called, call
            `Instance.update_skeleton()` on each instance that uses this skeleton.
        """
        if len(new_order) != len(self.nodes):
            raise ValueError(
                "New order of nodes must be the same length as the current nodes."
            )

        new_nodes = [self.require_node(node, add_missing=False) for node in new_order]
        self.nodes = new_nodes

    def match_nodes(self, other_nodes: list[str, Node]) -> tuple[list[int], list[int]]:
        """Return the order of nodes in the skeleton.

        Args:
            other_nodes: A list of node names or `Node` objects.

        Returns:
            A tuple of `skeleton_inds, `other_inds`.

            `skeleton_inds` contains the indices of the nodes in the skeleton that match
            the input nodes.

            `other_inds` contains the indices of the input nodes that match the nodes in
            the skeleton.

            These can be used to reorder point data to match the order of nodes in the
            skeleton.

        See also: match_nodes_cached
        """
        if isinstance(other_nodes, np.ndarray):
            other_nodes = other_nodes.tolist()
        if type(other_nodes) is not tuple:
            other_nodes = [x.name if type(x) is Node else x for x in other_nodes]

        skeleton_inds, other_inds = match_nodes_cached(
            tuple(self.node_names), tuple(other_nodes)
        )

        return list(skeleton_inds), list(other_inds)

    def matches(self, other: "Skeleton", require_same_order: bool = False) -> bool:
        """Check if this skeleton matches another skeleton's structure.

        Args:
            other: Another skeleton to compare with.
            require_same_order: If True, nodes must be in the same order.
                If False, only the node names and edges need to match.

        Returns:
            True if the skeletons match, False otherwise.

        Notes:
            Two skeletons match if they have the same nodes (by name) and edges.
            If require_same_order is True, the nodes must also be in the same order.
        """
        # Check if we have the same number of nodes
        if len(self.nodes) != len(other.nodes):
            return False

        # Check node names
        if require_same_order:
            if self.node_names != other.node_names:
                return False
        else:
            if set(self.node_names) != set(other.node_names):
                return False

        # Check edges (considering node name mapping if order differs)
        if len(self.edges) != len(other.edges):
            return False

        # Create edge sets for comparison
        self_edge_set = {
            (edge.source.name, edge.destination.name) for edge in self.edges
        }
        other_edge_set = {
            (edge.source.name, edge.destination.name) for edge in other.edges
        }

        if self_edge_set != other_edge_set:
            return False

        # Check symmetries
        if len(self.symmetries) != len(other.symmetries):
            return False

        self_sym_set = {
            frozenset(node.name for node in sym.nodes) for sym in self.symmetries
        }
        other_sym_set = {
            frozenset(node.name for node in sym.nodes) for sym in other.symmetries
        }

        return self_sym_set == other_sym_set

    def node_similarities(self, other: "Skeleton") -> dict[str, float]:
        """Calculate node overlap metrics with another skeleton.

        Args:
            other: Another skeleton to compare with.

        Returns:
            A dictionary with similarity metrics:
            - 'n_common': Number of nodes in common
            - 'n_self_only': Number of nodes only in this skeleton
            - 'n_other_only': Number of nodes only in the other skeleton
            - 'jaccard': Jaccard similarity (intersection/union)
            - 'dice': Dice coefficient (2*intersection/(n_self + n_other))
        """
        self_nodes = set(self.node_names)
        other_nodes = set(other.node_names)

        n_common = len(self_nodes & other_nodes)
        n_self_only = len(self_nodes - other_nodes)
        n_other_only = len(other_nodes - self_nodes)
        n_union = len(self_nodes | other_nodes)

        jaccard = n_common / n_union if n_union > 0 else 0
        dice = (
            2 * n_common / (len(self_nodes) + len(other_nodes))
            if (len(self_nodes) + len(other_nodes)) > 0
            else 0
        )

        return {
            "n_common": n_common,
            "n_self_only": n_self_only,
            "n_other_only": n_other_only,
            "jaccard": jaccard,
            "dice": dice,
        }

__annotations__ = {'nodes': 'list[Node]', 'edges': 'list[Edge]', 'symmetries': 'list[Symmetry]', 'name': 'str | None', '_name_to_node_cache': 'dict[str, Node]', '_node_to_ind_cache': 'dict[Node, int]'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = True class-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=False, added_eq=False, added_ordering=False, hashability=<Hashability.LEAVE_ALONE: 'leave_alone'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'A description of a set of landmark types and connections between them.\n\nSkeletons are represented by a directed graph composed of a set of `Node`s (landmark\ntypes such as body parts) and `Edge`s (connections between parts).\n\nAttributes:\n nodes: A list of `Node`s. May be specified as a list of strings to create new\n nodes from their names.\n edges: A list of `Edge`s. May be specified as a list of 2-tuples of string names\n or integer indices of `nodes`. Each edge corresponds to a pair of source and\n destination nodes forming a directed edge.\n symmetries: A list of `Symmetry`s. Each symmetry corresponds to symmetric body\n parts, such as `"left eye", "right eye"`. This is used when applying flip\n (reflection) augmentation to images in order to appropriately swap the\n indices of symmetric landmarks.\n name: A descriptive name for the `Skeleton`.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 97 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('nodes', 'edges', 'symmetries', 'name') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.skeleton' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('nodes', 'edges', 'symmetries', 'name', '_name_to_node_cache', '_node_to_ind_cache', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = ('_name_to_node_cache', '_node_to_ind_cache', 'edges', 'nodes', 'symmetries') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

edge_inds property

Edges indices as a list of 2-tuples.

edge_names property

Edge names as a list of 2-tuples with string node names.

node_names property

Names of the nodes associated with this skeleton as a list of strings.

symmetry_inds property

Symmetry indices as a list of 2-tuples.

symmetry_names property

Symmetry names as a list of 2-tuples with string node names.

__attrs_post_init__()

Ensure nodes are Nodes, edges are Edges, and Node map is updated.

Source code in sleap_io/model/skeleton.py
def __attrs_post_init__(self):
    """Ensure nodes are `Node`s, edges are `Edge`s, and `Node` map is updated."""
    self._convert_nodes()
    self._convert_edges()
    self._convert_symmetries()
    self.rebuild_cache()

__contains__(node)

Check if a node is in the skeleton.

Source code in sleap_io/model/skeleton.py
def __contains__(self, node: NodeOrIndex) -> bool:
    """Check if a node is in the skeleton."""
    if type(node) is str:
        return node in self._name_to_node_cache
    elif type(node) is Node:
        return node in self.nodes
    elif type(node) is int:
        return 0 <= node < len(self.nodes)
    else:
        raise ValueError(f"Invalid node type for skeleton: {node}")

__getitem__(idx)

Return a Node when indexing by name or integer.

Source code in sleap_io/model/skeleton.py
def __getitem__(self, idx: NodeOrIndex) -> Node:
    """Return a `Node` when indexing by name or integer."""
    if type(idx) is int:
        return self.nodes[idx]
    elif type(idx) is str:
        return self._name_to_node_cache[idx]
    else:
        raise IndexError(f"Invalid indexing argument for skeleton: {idx}")

__init__(nodes=NOTHING, edges=NOTHING, symmetries=NOTHING, name=None)

Method generated by attrs for class Skeleton.

Source code in sleap_io/model/skeleton.py
"""Data model for skeletons.

Skeletons are collections of nodes and edges which describe the landmarks associated
with a pose model. The edges represent the connections between them and may be used
differently depending on the underlying pose model.
"""

from __future__ import annotations

import re
import typing
from functools import lru_cache

import numpy as np
from attrs import define, field

__len__()

Return the number of nodes in the skeleton.

Source code in sleap_io/model/skeleton.py
def __len__(self) -> int:
    """Return the number of nodes in the skeleton."""
    return len(self.nodes)

__repr__()

Return a readable representation of the skeleton.

Source code in sleap_io/model/skeleton.py
def __repr__(self) -> str:
    """Return a readable representation of the skeleton."""
    nodes = ", ".join([f'"{node}"' for node in self.node_names])
    return f"Skeleton(nodes=[{nodes}], edges={self.edge_inds})"

__setattr__(name, val)

Method generated by attrs for class Skeleton.

add_edge(src, dst=None)

Add an Edge to the skeleton.

Parameters:

Name Type Description Default
src Union | Edge | tuple[Union, Union]

The source node specified as a Node, name or index.

required
dst Union | None

The destination node specified as a Node, name or index.

None
Source code in sleap_io/model/skeleton.py
def add_edge(
    self,
    src: NodeOrIndex | Edge | tuple[NodeOrIndex, NodeOrIndex],
    dst: NodeOrIndex | None = None,
):
    """Add an `Edge` to the skeleton.

    Args:
        src: The source node specified as a `Node`, name or index.
        dst: The destination node specified as a `Node`, name or index.
    """
    edge = None
    if type(src) is tuple:
        src, dst = src

    if is_node_or_index(src):
        if not is_node_or_index(dst):
            raise ValueError("Destination node must be specified.")

        src = self.require_node(src)
        dst = self.require_node(dst)
        edge = Edge(src, dst)

    if type(src) is Edge:
        edge = src

    if edge not in self.edges:
        self.edges.append(edge)

add_edges(edges)

Add multiple Edges to the skeleton.

Parameters:

Name Type Description Default
edges list[Edge | tuple[Union, Union]]

A list of Edge objects or 2-tuples of source and destination nodes.

required
Source code in sleap_io/model/skeleton.py
def add_edges(self, edges: list[Edge | tuple[NodeOrIndex, NodeOrIndex]]):
    """Add multiple `Edge`s to the skeleton.

    Args:
        edges: A list of `Edge` objects or 2-tuples of source and destination nodes.
    """
    for edge in edges:
        self.add_edge(edge)

add_node(node)

Add a Node to the skeleton.

Parameters:

Name Type Description Default
node Node | str

A Node object or a string name to create a new node.

required

Raises:

Type Description
ValueError

If the node already exists in the skeleton or if the node is not specified as a Node or string.

Source code in sleap_io/model/skeleton.py
def add_node(self, node: Node | str):
    """Add a `Node` to the skeleton.

    Args:
        node: A `Node` object or a string name to create a new node.

    Raises:
        ValueError: If the node already exists in the skeleton or if the node is
            not specified as a `Node` or string.
    """
    if node in self:
        raise ValueError(f"Node '{node}' already exists in the skeleton.")

    if type(node) is str:
        node = Node(node)

    if type(node) is not Node:
        raise ValueError(f"Invalid node type: {node} ({type(node)})")

    self.nodes.append(node)

    # Atomic update of the cache.
    self._name_to_node_cache[node.name] = node
    self._node_to_ind_cache[node] = len(self.nodes) - 1

add_nodes(nodes)

Add multiple Nodes to the skeleton.

Parameters:

Name Type Description Default
nodes list[Node | str]

A list of Node objects or string names to create new nodes.

required
Source code in sleap_io/model/skeleton.py
def add_nodes(self, nodes: list[Node | str]):
    """Add multiple `Node`s to the skeleton.

    Args:
        nodes: A list of `Node` objects or string names to create new nodes.
    """
    for node in nodes:
        self.add_node(node)

add_symmetries(symmetries)

Add multiple Symmetry relationships to the skeleton.

Parameters:

Name Type Description Default
symmetries list[Symmetry | tuple[Union, Union]]

A list of Symmetry objects or 2-tuples of symmetric nodes.

required
Source code in sleap_io/model/skeleton.py
def add_symmetries(
    self, symmetries: list[Symmetry | tuple[NodeOrIndex, NodeOrIndex]]
):
    """Add multiple `Symmetry` relationships to the skeleton.

    Args:
        symmetries: A list of `Symmetry` objects or 2-tuples of symmetric nodes.
    """
    for symmetry in symmetries:
        self.add_symmetry(*symmetry)

add_symmetry(node1=None, node2=None)

Add a symmetry relationship to the skeleton.

Parameters:

Name Type Description Default
node1 Symmetry | Union

The first node specified as a Node, name or index. If a Symmetry object is provided, it will be added directly to the skeleton.

None
node2 Union | None

The second node specified as a Node, name or index.

None
Source code in sleap_io/model/skeleton.py
def add_symmetry(
    self, node1: Symmetry | NodeOrIndex = None, node2: NodeOrIndex | None = None
):
    """Add a symmetry relationship to the skeleton.

    Args:
        node1: The first node specified as a `Node`, name or index. If a `Symmetry`
            object is provided, it will be added directly to the skeleton.
        node2: The second node specified as a `Node`, name or index.
    """
    symmetry = None
    if type(node1) is Symmetry:
        symmetry = node1
        node1, node2 = symmetry

    node1 = self.require_node(node1)
    node2 = self.require_node(node2)

    if symmetry is None:
        symmetry = Symmetry({node1, node2})

    if symmetry not in self.symmetries:
        self.symmetries.append(symmetry)

get_flipped_node_inds()

Returns node indices that should be switched when horizontally flipping.

This is useful as a lookup table for flipping the landmark coordinates when doing data augmentation.

Example

skel = Skeleton(["A", "B_left", "B_right", "C", "D_left", "D_right"]) skel.add_symmetry("B_left", "B_right") skel.add_symmetry("D_left", "D_right") skel.flipped_node_inds [0, 2, 1, 3, 5, 4] pose = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5]]) pose[skel.flipped_node_inds] array([[0, 0], [2, 2], [1, 1], [3, 3], [5, 5], [4, 4]])

Source code in sleap_io/model/skeleton.py
def get_flipped_node_inds(self) -> list[int]:
    """Returns node indices that should be switched when horizontally flipping.

    This is useful as a lookup table for flipping the landmark coordinates when
    doing data augmentation.

    Example:
        >>> skel = Skeleton(["A", "B_left", "B_right", "C", "D_left", "D_right"])
        >>> skel.add_symmetry("B_left", "B_right")
        >>> skel.add_symmetry("D_left", "D_right")
        >>> skel.flipped_node_inds
        [0, 2, 1, 3, 5, 4]
        >>> pose = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5]])
        >>> pose[skel.flipped_node_inds]
        array([[0, 0],
               [2, 2],
               [1, 1],
               [3, 3],
               [5, 5],
               [4, 4]])
    """
    flip_idx = np.arange(len(self.nodes))
    if len(self.symmetries) > 0:
        symmetry_inds = np.array(
            [(self.index(a), self.index(b)) for a, b in self.symmetries]
        )
        flip_idx[symmetry_inds[:, 0]] = symmetry_inds[:, 1]
        flip_idx[symmetry_inds[:, 1]] = symmetry_inds[:, 0]

    flip_idx = flip_idx.tolist()
    return flip_idx

index(node)

Return the index of a node specified as a Node or string name.

Source code in sleap_io/model/skeleton.py
def index(self, node: Node | str) -> int:
    """Return the index of a node specified as a `Node` or string name."""
    if type(node) is str:
        return self.index(self._name_to_node_cache[node])
    elif type(node) is Node:
        return self._node_to_ind_cache[node]
    else:
        raise IndexError(f"Invalid indexing argument for skeleton: {node}")

infer_symmetries_by_name(token_pairs=None)

Infer left/right symmetric node pairs from node names.

Useful when a skeleton has no symmetries defined (e.g. imported from a format that does not carry symmetry metadata) but its node names encode laterality, so that flip-dependent tooling (augmentation, QC) still works. Names are matched by splitting on separators (_, -, ., space), camelCase boundaries, and letter/digit boundaries, then pairing nodes that share a stem but differ by a single left/right token. For example, Ear_L/Ear_R, left_eye/right_eye, LeftPaw/RightPaw, and L1/R1 all pair up.

This is intentionally non-mutating and conservative: it returns suggested pairs rather than writing them onto the skeleton, since a wrong guess would silently corrupt flip augmentation. Apply the result explicitly if desired, e.g. skel.add_symmetries(skel.infer_symmetries_by_name()). Node names without a delimited or camelCase/digit token boundary (e.g. larm) and truly non-semantic pairings (e.g. L1/L2) cannot be inferred and must be declared with add_symmetry.

Parameters:

Name Type Description Default
token_pairs list[tuple[str, str]] | None

List of (left_token, right_token) string pairs used to recognize laterality, matched case-insensitively against whole name segments. Defaults to [("left", "right"), ("l", "r")].

None

Returns:

Type Description
list[tuple[int, int]]

A list of (left_index, right_index) node-index pairs, ordered by left index. Each node appears in at most one pair, and only stems with exactly one left and one right member are paired (ambiguous groups are skipped).

Example

skel = Skeleton(["nose", "eye_L", "eye_R", "ear_L", "ear_R"]) skel.infer_symmetries_by_name() [(1, 2), (3, 4)] skel.add_symmetries(skel.infer_symmetries_by_name()) skel.symmetry_names [('eye_L', 'eye_R'), ('ear_L', 'ear_R')]

Source code in sleap_io/model/skeleton.py
def infer_symmetries_by_name(
    self,
    token_pairs: list[tuple[str, str]] | None = None,
) -> list[tuple[int, int]]:
    """Infer left/right symmetric node pairs from node names.

    Useful when a skeleton has no symmetries defined (e.g. imported from a
    format that does not carry symmetry metadata) but its node names encode
    laterality, so that flip-dependent tooling (augmentation, QC) still
    works. Names are matched by splitting on separators (`_`, `-`, `.`,
    space), camelCase boundaries, and letter/digit boundaries, then pairing
    nodes that share a stem but differ by a single left/right token. For
    example, `Ear_L`/`Ear_R`, `left_eye`/`right_eye`, `LeftPaw`/`RightPaw`,
    and `L1`/`R1` all pair up.

    This is intentionally **non-mutating** and conservative: it returns
    suggested pairs rather than writing them onto the skeleton, since a wrong
    guess would silently corrupt flip augmentation. Apply the result
    explicitly if desired, e.g.
    `skel.add_symmetries(skel.infer_symmetries_by_name())`. Node names
    without a delimited or camelCase/digit token boundary (e.g. `larm`) and
    truly non-semantic pairings (e.g. `L1`/`L2`) cannot be inferred and must
    be declared with `add_symmetry`.

    Args:
        token_pairs: List of `(left_token, right_token)` string pairs used to
            recognize laterality, matched case-insensitively against whole
            name segments. Defaults to `[("left", "right"), ("l", "r")]`.

    Returns:
        A list of `(left_index, right_index)` node-index pairs, ordered by
        left index. Each node appears in at most one pair, and only stems
        with exactly one left and one right member are paired (ambiguous
        groups are skipped).

    Example:
        >>> skel = Skeleton(["nose", "eye_L", "eye_R", "ear_L", "ear_R"])
        >>> skel.infer_symmetries_by_name()
        [(1, 2), (3, 4)]
        >>> skel.add_symmetries(skel.infer_symmetries_by_name())
        >>> skel.symmetry_names
        [('eye_L', 'eye_R'), ('ear_L', 'ear_R')]
    """
    return infer_symmetry_pairs_by_name(self.node_names, token_pairs=token_pairs)

match_nodes(other_nodes)

Return the order of nodes in the skeleton.

Parameters:

Name Type Description Default
other_nodes list[str, Node]

A list of node names or Node objects.

required

Returns:

Type Description
tuple[list[int], list[int]]

A tuple of skeleton_inds,other_inds`.

skeleton_inds contains the indices of the nodes in the skeleton that match the input nodes.

other_inds contains the indices of the input nodes that match the nodes in the skeleton.

These can be used to reorder point data to match the order of nodes in the skeleton.

See also: match_nodes_cached

Source code in sleap_io/model/skeleton.py
def match_nodes(self, other_nodes: list[str, Node]) -> tuple[list[int], list[int]]:
    """Return the order of nodes in the skeleton.

    Args:
        other_nodes: A list of node names or `Node` objects.

    Returns:
        A tuple of `skeleton_inds, `other_inds`.

        `skeleton_inds` contains the indices of the nodes in the skeleton that match
        the input nodes.

        `other_inds` contains the indices of the input nodes that match the nodes in
        the skeleton.

        These can be used to reorder point data to match the order of nodes in the
        skeleton.

    See also: match_nodes_cached
    """
    if isinstance(other_nodes, np.ndarray):
        other_nodes = other_nodes.tolist()
    if type(other_nodes) is not tuple:
        other_nodes = [x.name if type(x) is Node else x for x in other_nodes]

    skeleton_inds, other_inds = match_nodes_cached(
        tuple(self.node_names), tuple(other_nodes)
    )

    return list(skeleton_inds), list(other_inds)

matches(other, require_same_order=False)

Check if this skeleton matches another skeleton's structure.

Parameters:

Name Type Description Default
other Skeleton

Another skeleton to compare with.

required
require_same_order bool

If True, nodes must be in the same order. If False, only the node names and edges need to match.

False

Returns:

Type Description
bool

True if the skeletons match, False otherwise.

Notes

Two skeletons match if they have the same nodes (by name) and edges. If require_same_order is True, the nodes must also be in the same order.

Source code in sleap_io/model/skeleton.py
def matches(self, other: "Skeleton", require_same_order: bool = False) -> bool:
    """Check if this skeleton matches another skeleton's structure.

    Args:
        other: Another skeleton to compare with.
        require_same_order: If True, nodes must be in the same order.
            If False, only the node names and edges need to match.

    Returns:
        True if the skeletons match, False otherwise.

    Notes:
        Two skeletons match if they have the same nodes (by name) and edges.
        If require_same_order is True, the nodes must also be in the same order.
    """
    # Check if we have the same number of nodes
    if len(self.nodes) != len(other.nodes):
        return False

    # Check node names
    if require_same_order:
        if self.node_names != other.node_names:
            return False
    else:
        if set(self.node_names) != set(other.node_names):
            return False

    # Check edges (considering node name mapping if order differs)
    if len(self.edges) != len(other.edges):
        return False

    # Create edge sets for comparison
    self_edge_set = {
        (edge.source.name, edge.destination.name) for edge in self.edges
    }
    other_edge_set = {
        (edge.source.name, edge.destination.name) for edge in other.edges
    }

    if self_edge_set != other_edge_set:
        return False

    # Check symmetries
    if len(self.symmetries) != len(other.symmetries):
        return False

    self_sym_set = {
        frozenset(node.name for node in sym.nodes) for sym in self.symmetries
    }
    other_sym_set = {
        frozenset(node.name for node in sym.nodes) for sym in other.symmetries
    }

    return self_sym_set == other_sym_set

node_similarities(other)

Calculate node overlap metrics with another skeleton.

Parameters:

Name Type Description Default
other Skeleton

Another skeleton to compare with.

required

Returns:

Type Description
dict[str, float]

A dictionary with similarity metrics: - 'n_common': Number of nodes in common - 'n_self_only': Number of nodes only in this skeleton - 'n_other_only': Number of nodes only in the other skeleton - 'jaccard': Jaccard similarity (intersection/union) - 'dice': Dice coefficient (2*intersection/(n_self + n_other))

Source code in sleap_io/model/skeleton.py
def node_similarities(self, other: "Skeleton") -> dict[str, float]:
    """Calculate node overlap metrics with another skeleton.

    Args:
        other: Another skeleton to compare with.

    Returns:
        A dictionary with similarity metrics:
        - 'n_common': Number of nodes in common
        - 'n_self_only': Number of nodes only in this skeleton
        - 'n_other_only': Number of nodes only in the other skeleton
        - 'jaccard': Jaccard similarity (intersection/union)
        - 'dice': Dice coefficient (2*intersection/(n_self + n_other))
    """
    self_nodes = set(self.node_names)
    other_nodes = set(other.node_names)

    n_common = len(self_nodes & other_nodes)
    n_self_only = len(self_nodes - other_nodes)
    n_other_only = len(other_nodes - self_nodes)
    n_union = len(self_nodes | other_nodes)

    jaccard = n_common / n_union if n_union > 0 else 0
    dice = (
        2 * n_common / (len(self_nodes) + len(other_nodes))
        if (len(self_nodes) + len(other_nodes)) > 0
        else 0
    )

    return {
        "n_common": n_common,
        "n_self_only": n_self_only,
        "n_other_only": n_other_only,
        "jaccard": jaccard,
        "dice": dice,
    }

rebuild_cache(nodes=None)

Rebuild the node name/index to Node map caches.

Parameters:

Name Type Description Default
nodes list[Node] | None

A list of Node objects to update the cache with. If not provided, the cache will be updated with the current nodes in the skeleton. If nodes are provided, the cache will be updated with the provided nodes, but the current nodes in the skeleton will not be updated. Default is None.

None
Notes

This function should be called when nodes or node list is mutated to update the lookup caches for indexing nodes by name or Node object.

This is done automatically when nodes are added or removed from the skeleton using the convenience methods in this class.

This method only needs to be used when manually mutating nodes or the node list directly.

Source code in sleap_io/model/skeleton.py
def rebuild_cache(self, nodes: list[Node] | None = None):
    """Rebuild the node name/index to `Node` map caches.

    Args:
        nodes: A list of `Node` objects to update the cache with. If not provided,
            the cache will be updated with the current nodes in the skeleton. If
            nodes are provided, the cache will be updated with the provided nodes,
            but the current nodes in the skeleton will not be updated. Default is
            `None`.

    Notes:
        This function should be called when nodes or node list is mutated to update
        the lookup caches for indexing nodes by name or `Node` object.

        This is done automatically when nodes are added or removed from the skeleton
        using the convenience methods in this class.

        This method only needs to be used when manually mutating nodes or the node
        list directly.
    """
    if nodes is None:
        nodes = self.nodes
    self._name_to_node_cache = {node.name: node for node in nodes}
    self._node_to_ind_cache = {node: i for i, node in enumerate(nodes)}

remove_node(node)

Remove a single node from the skeleton.

Parameters:

Name Type Description Default
node Union

The node to remove. Can be specified as a string name, integer index, or Node object.

required
Notes

This method handles updating the lookup caches necessary for indexing nodes by name.

Any edges and symmetries that are connected to the removed node will also be removed.

Warning

This method does NOT update instances that use this skeleton to reflect changes.

It is recommended to use the Labels.remove_nodes() method which will update all contained instances to reflect the changes made to the skeleton.

To manually update instances after this method is called, call Instance.update_skeleton() on each instance that uses this skeleton.

Source code in sleap_io/model/skeleton.py
def remove_node(self, node: NodeOrIndex):
    """Remove a single node from the skeleton.

    Args:
        node: The node to remove. Can be specified as a string name, integer index,
            or `Node` object.

    Notes:
        This method handles updating the lookup caches necessary for indexing nodes
        by name.

        Any edges and symmetries that are connected to the removed node will also be
        removed.

    Warning:
        **This method does NOT update instances** that use this skeleton to reflect
        changes.

        It is recommended to use the `Labels.remove_nodes()` method which will
        update all contained instances to reflect the changes made to the skeleton.

        To manually update instances after this method is called, call
        `Instance.update_skeleton()` on each instance that uses this skeleton.
    """
    self.remove_nodes([node])

remove_nodes(nodes)

Remove nodes from the skeleton.

Parameters:

Name Type Description Default
nodes list[Union]

A list of node names, indices, or Node objects to remove.

required
Notes

This method handles updating the lookup caches necessary for indexing nodes by name.

Any edges and symmetries that are connected to the removed nodes will also be removed.

Warning

This method does NOT update instances that use this skeleton to reflect changes.

It is recommended to use the Labels.remove_nodes() method which will update all contained to reflect the changes made to the skeleton.

To manually update instances after this method is called, call instance.update_nodes() on each instance that uses this skeleton.

Source code in sleap_io/model/skeleton.py
def remove_nodes(self, nodes: list[NodeOrIndex]):
    """Remove nodes from the skeleton.

    Args:
        nodes: A list of node names, indices, or `Node` objects to remove.

    Notes:
        This method handles updating the lookup caches necessary for indexing nodes
        by name.

        Any edges and symmetries that are connected to the removed nodes will also
        be removed.

    Warning:
        **This method does NOT update instances** that use this skeleton to reflect
        changes.

        It is recommended to use the `Labels.remove_nodes()` method which will
        update all contained to reflect the changes made to the skeleton.

        To manually update instances after this method is called, call
        `instance.update_nodes()` on each instance that uses this skeleton.
    """
    # Standardize input and make a pre-mutation copy before keys are changed.
    rm_node_objs = [self.require_node(node, add_missing=False) for node in nodes]

    # Remove nodes from the skeleton.
    for node in rm_node_objs:
        self.nodes.remove(node)
        del self._name_to_node_cache[node.name]

    # Remove edges connected to the removed nodes.
    self.edges = [
        edge
        for edge in self.edges
        if edge.source not in rm_node_objs and edge.destination not in rm_node_objs
    ]

    # Remove symmetries connected to the removed nodes.
    self.symmetries = [
        symmetry
        for symmetry in self.symmetries
        if symmetry.nodes.isdisjoint(rm_node_objs)
    ]

    # Update node index map.
    self.rebuild_cache()

rename_node(old_name, new_name)

Rename a single node in the skeleton.

Parameters:

Name Type Description Default
old_name Union

The name of the node to rename. Can also be specified as an integer index or Node object.

required
new_name str

The new name for the node.

required
Source code in sleap_io/model/skeleton.py
def rename_node(self, old_name: NodeOrIndex, new_name: str):
    """Rename a single node in the skeleton.

    Args:
        old_name: The name of the node to rename. Can also be specified as an
            integer index or `Node` object.
        new_name: The new name for the node.
    """
    self.rename_nodes({old_name: new_name})

rename_nodes(name_map)

Rename nodes in the skeleton.

Parameters:

Name Type Description Default
name_map dict[Union, str] | list[str]

A dictionary mapping old node names to new node names. Keys can be specified as Node objects, integer indices, or string names. Values must be specified as string names.

If a list of strings is provided of the same length as the current nodes, the nodes will be renamed to the names in the list in order.

required

Raises:

Type Description
ValueError

If the new node names exist in the skeleton or if the old node names are not found in the skeleton.

Notes

This method should always be used when renaming nodes in the skeleton as it handles updating the lookup caches necessary for indexing nodes by name.

After renaming, instances using this skeleton do NOT need to be updated as the nodes are stored by reference in the skeleton, so changes are reflected automatically.

Example

skel = Skeleton(["A", "B", "C"], edges=[("A", "B"), ("B", "C")]) skel.rename_nodes({"A": "X", "B": "Y", "C": "Z"}) skel.node_names ["X", "Y", "Z"] skel.rename_nodes(["a", "b", "c"]) skel.node_names ["a", "b", "c"]

Source code in sleap_io/model/skeleton.py
def rename_nodes(self, name_map: dict[NodeOrIndex, str] | list[str]):
    """Rename nodes in the skeleton.

    Args:
        name_map: A dictionary mapping old node names to new node names. Keys can be
            specified as `Node` objects, integer indices, or string names. Values
            must be specified as string names.

            If a list of strings is provided of the same length as the current
            nodes, the nodes will be renamed to the names in the list in order.

    Raises:
        ValueError: If the new node names exist in the skeleton or if the old node
            names are not found in the skeleton.

    Notes:
        This method should always be used when renaming nodes in the skeleton as it
        handles updating the lookup caches necessary for indexing nodes by name.

        After renaming, instances using this skeleton **do NOT need to be updated**
        as the nodes are stored by reference in the skeleton, so changes are
        reflected automatically.

    Example:
        >>> skel = Skeleton(["A", "B", "C"], edges=[("A", "B"), ("B", "C")])
        >>> skel.rename_nodes({"A": "X", "B": "Y", "C": "Z"})
        >>> skel.node_names
        ["X", "Y", "Z"]
        >>> skel.rename_nodes(["a", "b", "c"])
        >>> skel.node_names
        ["a", "b", "c"]
    """
    if type(name_map) is list:
        if len(name_map) != len(self.nodes):
            raise ValueError(
                "List of new node names must be the same length as the current "
                "nodes."
            )
        name_map = {node: name for node, name in zip(self.nodes, name_map)}

    for old_name, new_name in name_map.items():
        if type(old_name) is Node:
            old_name = old_name.name
        if type(old_name) is int:
            old_name = self.nodes[old_name].name

        if old_name not in self._name_to_node_cache:
            raise ValueError(f"Node '{old_name}' not found in the skeleton.")
        if new_name in self._name_to_node_cache:
            raise ValueError(f"Node '{new_name}' already exists in the skeleton.")

        node = self._name_to_node_cache[old_name]
        node.name = new_name
        self._name_to_node_cache[new_name] = node
        del self._name_to_node_cache[old_name]

reorder_nodes(new_order)

Reorder nodes in the skeleton.

Parameters:

Name Type Description Default
new_order list[Union]

A list of node names, indices, or Node objects specifying the new order of the nodes.

required

Raises:

Type Description
ValueError

If the new order of nodes is not the same length as the current nodes.

Notes

This method handles updating the lookup caches necessary for indexing nodes by name.

Warning

After reordering, instances using this skeleton do not need to be updated as the nodes are stored by reference in the skeleton.

However, the order that points are stored in the instances will not be updated to match the new order of the nodes in the skeleton. This should not matter unless the ordering of the keys in the Instance.points dictionary is used instead of relying on the skeleton node order.

To make sure these are aligned, it is recommended to use the Labels.reorder_nodes() method which will update all contained instances to reflect the changes made to the skeleton.

To manually update instances after this method is called, call Instance.update_skeleton() on each instance that uses this skeleton.

Source code in sleap_io/model/skeleton.py
def reorder_nodes(self, new_order: list[NodeOrIndex]):
    """Reorder nodes in the skeleton.

    Args:
        new_order: A list of node names, indices, or `Node` objects specifying the
            new order of the nodes.

    Raises:
        ValueError: If the new order of nodes is not the same length as the current
            nodes.

    Notes:
        This method handles updating the lookup caches necessary for indexing nodes
        by name.

    Warning:
        After reordering, instances using this skeleton do not need to be updated as
        the nodes are stored by reference in the skeleton.

        However, the order that points are stored in the instances will not be
        updated to match the new order of the nodes in the skeleton. This should not
        matter unless the ordering of the keys in the `Instance.points` dictionary
        is used instead of relying on the skeleton node order.

        To make sure these are aligned, it is recommended to use the
        `Labels.reorder_nodes()` method which will update all contained instances to
        reflect the changes made to the skeleton.

        To manually update instances after this method is called, call
        `Instance.update_skeleton()` on each instance that uses this skeleton.
    """
    if len(new_order) != len(self.nodes):
        raise ValueError(
            "New order of nodes must be the same length as the current nodes."
        )

    new_nodes = [self.require_node(node, add_missing=False) for node in new_order]
    self.nodes = new_nodes

require_node(node, add_missing=True)

Return a Node object, handling indexing and adding missing nodes.

Parameters:

Name Type Description Default
node Union

A Node object, name or index.

required
add_missing bool

If True, missing nodes will be added to the skeleton. If False, an error will be raised if the node is not found. Default is True.

True

Returns:

Type Description
Node

The Node object.

Raises:

Type Description
IndexError

If the node is not found in the skeleton and add_missing is False.

Source code in sleap_io/model/skeleton.py
def require_node(self, node: NodeOrIndex, add_missing: bool = True) -> Node:
    """Return a `Node` object, handling indexing and adding missing nodes.

    Args:
        node: A `Node` object, name or index.
        add_missing: If `True`, missing nodes will be added to the skeleton. If
            `False`, an error will be raised if the node is not found. Default is
            `True`.

    Returns:
        The `Node` object.

    Raises:
        IndexError: If the node is not found in the skeleton and `add_missing` is
            `False`.
    """
    if node not in self:
        if add_missing:
            self.add_node(node)
        else:
            raise IndexError(f"Node '{node}' not found in the skeleton.")

    if type(node) is Node:
        return node

    return self[node]

Symmetry

A relationship between a pair of nodes denoting their left/right pairing.

Attributes:

Name Type Description
nodes

A set of two Nodes.

Methods:

Name Description
__eq__

Method generated by attrs for class Symmetry.

__getitem__

Return the first node.

__init__

Method generated by attrs for class Symmetry.

__iter__

Iterate over the symmetric nodes.

__repr__

Method generated by attrs for class Symmetry.

__setattr__

Method generated by attrs for class Symmetry.

Source code in sleap_io/model/skeleton.py
@define
class Symmetry:
    """A relationship between a pair of nodes denoting their left/right pairing.

    Attributes:
        nodes: A set of two `Node`s.
    """

    nodes: set[Node] = field(converter=set, validator=lambda _, __, val: len(val) == 2)

    def __iter__(self):
        """Iterate over the symmetric nodes."""
        return iter(self.nodes)

    def __getitem__(self, idx) -> Node:
        """Return the first node."""
        for i, node in enumerate(self.nodes):
            if i == idx:
                return node

__annotations__ = {'nodes': 'set[Node]'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = True class-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'A relationship between a pair of nodes denoting their left/right pairing.\n\nAttributes:\n nodes: A set of two `Node`s.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 57 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('nodes',) class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.skeleton' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('nodes', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__eq__(other)

Method generated by attrs for class Symmetry.

Source code in sleap_io/model/skeleton.py
@define(eq=False)
class Node:
    """A landmark type within a `Skeleton`.

__getitem__(idx)

Return the first node.

Source code in sleap_io/model/skeleton.py
def __getitem__(self, idx) -> Node:
    """Return the first node."""
    for i, node in enumerate(self.nodes):
        if i == idx:
            return node

__init__(nodes)

Method generated by attrs for class Symmetry.

Source code in sleap_io/model/skeleton.py
This typically corresponds to a unique landmark within a skeleton, such as the "left
eye".

Attributes:
    name: Descriptive label for the landmark.

__iter__()

Iterate over the symmetric nodes.

Source code in sleap_io/model/skeleton.py
def __iter__(self):
    """Iterate over the symmetric nodes."""
    return iter(self.nodes)

__repr__()

Method generated by attrs for class Symmetry.

Source code in sleap_io/model/skeleton.py
"""Data model for skeletons.

Skeletons are collections of nodes and edges which describe the landmarks associated
with a pose model. The edges represent the connections between them and may be used
differently depending on the underlying pose model.
"""

from __future__ import annotations

import re
import typing
from functools import lru_cache

import numpy as np
from attrs import define, field

__setattr__(name, val)

Method generated by attrs for class Symmetry.

infer_symmetry_pairs_by_name(node_names, token_pairs=None)

Infer left/right symmetric node pairs from node names.

See Skeleton.infer_symmetries_by_name for the full description. This is the underlying name-only implementation, exposed for callers that have a list of node names but not a Skeleton.

Parameters:

Name Type Description Default
node_names list[str]

Ordered node names (index = node index).

required
token_pairs list[tuple[str, str]] | None

List of (left_token, right_token) pairs, matched case-insensitively against whole name segments. Defaults to [("left", "right"), ("l", "r")].

None

Returns:

Type Description
list[tuple[int, int]]

A list of (left_index, right_index) pairs ordered by left index. Each node appears in at most one pair; only stems with exactly one left and one right member are paired.

Source code in sleap_io/model/skeleton.py
def infer_symmetry_pairs_by_name(
    node_names: list[str],
    token_pairs: list[tuple[str, str]] | None = None,
) -> list[tuple[int, int]]:
    """Infer left/right symmetric node pairs from node names.

    See `Skeleton.infer_symmetries_by_name` for the full description. This is the
    underlying name-only implementation, exposed for callers that have a list of
    node names but not a `Skeleton`.

    Args:
        node_names: Ordered node names (index = node index).
        token_pairs: List of `(left_token, right_token)` pairs, matched
            case-insensitively against whole name segments. Defaults to
            `[("left", "right"), ("l", "r")]`.

    Returns:
        A list of `(left_index, right_index)` pairs ordered by left index. Each
        node appears in at most one pair; only stems with exactly one left and
        one right member are paired.
    """
    if token_pairs is None:
        token_pairs = DEFAULT_LR_TOKENS

    left_tokens = {left.lower() for left, _ in token_pairs}
    right_tokens = {right.lower() for _, right in token_pairs}
    # A token declared as both a left and a right marker is ambiguous; drop it.
    ambiguous = left_tokens & right_tokens
    left_tokens -= ambiguous
    right_tokens -= ambiguous
    side_tokens = left_tokens | right_tokens

    # Group nodes by stem (name minus its single side token) and side.
    groups: dict[str, dict[str, list[int]]] = {}
    for idx, name in enumerate(node_names):
        segments = _split_name_segments(name)
        side_positions = [i for i, seg in enumerate(segments) if seg in side_tokens]
        # Require exactly one side token: none = not a lateral node, more than one
        # = ambiguous.
        if len(side_positions) != 1:
            continue
        pos = side_positions[0]
        side = "left" if segments[pos] in left_tokens else "right"
        stem = "".join(segments[:pos] + segments[pos + 1 :])
        # A bare side token (e.g. "L", "left") carries no landmark identity.
        if not stem:
            continue
        bucket = groups.setdefault(stem, {"left": [], "right": []})
        bucket[side].append(idx)

    pairs: list[tuple[int, int]] = []
    for bucket in groups.values():
        # Only pair unambiguous 1:1 stems (exactly one left and one right member).
        if len(bucket["left"]) == 1 and len(bucket["right"]) == 1:
            pairs.append((bucket["left"][0], bucket["right"][0]))

    pairs.sort(key=lambda pair: pair[0])
    return pairs

is_node_or_index(obj)

Check if an object is a Node, string name or integer index.

Parameters:

Name Type Description Default
obj Any

The object to check.

required
Notes

This is mainly for backwards compatibility with Python versions < 3.10 where generics can't be used with isinstance. In newer Python, this is equivalent to isinstance(obj, NodeOrIndex).

Source code in sleap_io/model/skeleton.py
def is_node_or_index(obj: typing.Any) -> bool:
    """Check if an object is a `Node`, string name or integer index.

    Args:
        obj: The object to check.

    Notes:
        This is mainly for backwards compatibility with Python versions < 3.10 where
        generics can't be used with `isinstance`. In newer Python, this is equivalent
        to `isinstance(obj, NodeOrIndex)`.
    """
    return isinstance(obj, (Node, str, int))

match_nodes_cached(node_names_a, node_names_b)

Match nodes in two skeletons by name.

Parameters:

Name Type Description Default
node_names_a tuple[str]

A tuple of node names for the first skeleton.

required
node_names_b tuple[str]

A tuple of node names for the second skeleton.

required

Returns:

Type Description
tuple[tuple[int], tuple[int]]

A tuple of node_inds_a,node_inds_b` with corresponding indices for the nodes of their intersection.

The two tuples can be used to reorder point data to match the order of nodes in the first skeleton.

Notes

This function is cached to avoid recomputing the node matching for the same node names. This is useful when matching nodes between skeletons in a loop or when matching nodes between many instances.

The indices returned are in the order of the first skeleton.

Source code in sleap_io/model/skeleton.py
@lru_cache
def match_nodes_cached(
    node_names_a: tuple[str], node_names_b: tuple[str]
) -> tuple[tuple[int], tuple[int]]:
    """Match nodes in two skeletons by name.

    Args:
        node_names_a: A tuple of node names for the first skeleton.
        node_names_b: A tuple of node names for the second skeleton.

    Returns:
        A tuple of `node_inds_a, `node_inds_b` with corresponding indices for the nodes
        of their intersection.

        The two tuples can be used to reorder point data to match the order of nodes in
        the first skeleton.

    Notes:
        This function is cached to avoid recomputing the node matching for the same
        node names. This is useful when matching nodes between skeletons in a loop or
        when matching nodes between many instances.

        The indices returned are in the order of the first skeleton.
    """
    # Convert lists to numpy arrays if they aren't already.
    a_arr = np.array(node_names_a)
    b_arr = np.array(node_names_b)

    # Create a mapping of values to indices for array b.
    b_index_map = {val: i for i, val in enumerate(b_arr)}

    # Find indices where elements from a exist in b.
    mask = np.isin(a_arr, b_arr)
    inds_a = tuple(np.where(mask)[0].tolist())

    # Get corresponding indices in b.
    inds_b = tuple([b_index_map[val] for val in a_arr[mask]])

    return inds_a, inds_b