Skip to content

features

sleap.qc.features

Feature extraction for Label QC.

Modules:

Name Description
appearance

Appearance-outlier detection: points placed on occluders / the wrong object.

baseline

Baseline feature extraction (v2 features).

chirality

Chirality (left/right mirror flip) features.

duplicate_split

Split/duplicate instance features for frame-level QC.

missing_node

Missing-node detection: labelable points left unlabeled.

ordering

Keypoint ordering features: turning angles along chains.

pose_split

Pose-split (chimera) features.

reference

Reference-based features: nearest neighbor distance.

skeleton

Skeleton graph analysis utilities.

structural

Structural features: curvature, convex hull.

visibility

Visibility pattern features.

Classes:

Name Description
BaselineFeatureExtractor

Extract baseline (v2) features from pose instances.

NearestNeighborScorer

Score instances by distance to nearest neighbor in reference set.

SkeletonAnalyzer

Analyze skeleton topology to determine feature applicability.

VisibilityModel

Learn and score visibility patterns.

Functions:

Name Description
compute_convex_hull

Compute convex hull metrics for pose compactness.

compute_curvature

Compute curvature along a chain of nodes (e.g., spine).

normalize_pose

Normalize pose to unit scale and center.

BaselineFeatureExtractor

Extract baseline (v2) features from pose instances.

Features include: - Edge length z-scores - Joint angle z-scores - Pairwise distance z-scores - Bounding box area z-score - Node isolation (centroid distance) - Symmetry consistency - Visibility features

Methods:

Name Description
__init__

Initialize extractor.

extract

Extract feature vector from a single instance.

fit

Compute statistics from reference instances.

Source code in sleap/qc/features/baseline.py
class BaselineFeatureExtractor:
    """Extract baseline (v2) features from pose instances.

    Features include:
    - Edge length z-scores
    - Joint angle z-scores
    - Pairwise distance z-scores
    - Bounding box area z-score
    - Node isolation (centroid distance)
    - Symmetry consistency
    - Visibility features
    """

    def __init__(
        self,
        edges: list[tuple[int, int]],
        n_nodes: int,
        symmetry_pairs: Optional[list[tuple[int, int]]] = None,
    ):
        """Initialize extractor.

        Args:
            edges: List of edge tuples (src_idx, dst_idx).
            n_nodes: Number of nodes in skeleton.
            symmetry_pairs: Optional list of symmetric node pairs.
        """
        self.edges = edges
        self.n_nodes = n_nodes
        self.symmetry_pairs = symmetry_pairs or []
        self.stats: Optional[DatasetStats] = None
        self._adjacency: Optional[dict[int, list[int]]] = None

    def fit(self, instances: list[np.ndarray]) -> "BaselineFeatureExtractor":
        """Compute statistics from reference instances.

        Args:
            instances: List of (n_nodes, 2) arrays of pose coordinates.
                NaN values indicate invisible nodes.

        Returns:
            Self for chaining.
        """
        # Build adjacency
        self._adjacency = {i: [] for i in range(self.n_nodes)}
        for src, dst in self.edges:
            self._adjacency[src].append(dst)
            self._adjacency[dst].append(src)

        # Collect edge lengths
        edge_lengths: dict[tuple[int, int], list[float]] = {
            tuple(sorted(e)): [] for e in self.edges
        }
        for points in instances:
            for src, dst in self.edges:
                p1, p2 = points[src], points[dst]
                if not (np.isnan(p1).any() or np.isnan(p2).any()):
                    key = tuple(sorted([src, dst]))
                    edge_lengths[key].append(np.linalg.norm(p2 - p1))

        # Collect pairwise distances
        pairwise_dists: dict[tuple[int, int], list[float]] = {}
        for i in range(self.n_nodes):
            for j in range(i + 1, self.n_nodes):
                pairwise_dists[(i, j)] = []

        for points in instances:
            for i in range(self.n_nodes):
                for j in range(i + 1, self.n_nodes):
                    p1, p2 = points[i], points[j]
                    if not (np.isnan(p1).any() or np.isnan(p2).any()):
                        pairwise_dists[(i, j)].append(np.linalg.norm(p2 - p1))

        # Collect joint angles
        angle_values: dict[tuple[int, int, int], list[float]] = {}
        for center, neighbors in self._adjacency.items():
            if len(neighbors) < 2:
                continue
            for i, n1 in enumerate(neighbors):
                for n2 in neighbors[i + 1 :]:
                    key = (center, min(n1, n2), max(n1, n2))
                    angle_values[key] = []

        for points in instances:
            for center, neighbors in self._adjacency.items():
                if len(neighbors) < 2:
                    continue
                pc = points[center]
                if np.isnan(pc).any():
                    continue

                for i, n1 in enumerate(neighbors):
                    for n2 in neighbors[i + 1 :]:
                        p1 = points[n1]
                        p2 = points[n2]
                        if np.isnan(p1).any() or np.isnan(p2).any():
                            continue

                        v1 = p1 - pc
                        v2 = p2 - pc
                        norm1 = np.linalg.norm(v1)
                        norm2 = np.linalg.norm(v2)
                        if norm1 < 1e-6 or norm2 < 1e-6:
                            continue

                        cos_angle = np.clip(np.dot(v1, v2) / (norm1 * norm2), -1.0, 1.0)
                        angle = np.arccos(cos_angle)

                        key = (center, min(n1, n2), max(n1, n2))
                        angle_values[key].append(angle)

        # Collect bbox areas
        bbox_areas = []
        for points in instances:
            visible = points[~np.isnan(points[:, 0])]
            if len(visible) >= 2:
                min_xy = visible.min(axis=0)
                max_xy = visible.max(axis=0)
                area = (max_xy[0] - min_xy[0]) * (max_xy[1] - min_xy[1])
                bbox_areas.append(area)

        # Compute statistics
        def safe_mean(values: list[float]) -> float:
            return float(np.mean(values)) if values else 0.0

        def safe_std(values: list[float], min_val: float = 1e-6) -> float:
            return max(float(np.std(values)) if values else min_val, min_val)

        self.stats = DatasetStats(
            edge_means={k: safe_mean(v) for k, v in edge_lengths.items()},
            edge_stds={k: safe_std(v) for k, v in edge_lengths.items()},
            pairwise_means={k: safe_mean(v) for k, v in pairwise_dists.items()},
            pairwise_stds={k: safe_std(v) for k, v in pairwise_dists.items()},
            angle_means={k: safe_mean(v) for k, v in angle_values.items()},
            angle_stds={k: safe_std(v) for k, v in angle_values.items()},
            bbox_area_mean=safe_mean(bbox_areas),
            bbox_area_std=safe_std(bbox_areas),
        )

        return self

    def extract(self, points: np.ndarray) -> np.ndarray:
        """Extract feature vector from a single instance.

        Args:
            points: (n_nodes, 2) array of pose coordinates.

        Returns:
            (n_features,) feature vector.
        """
        if self.stats is None:
            raise ValueError("Must call fit() before extract()")

        # Edge features
        edge_zscores = self._compute_edge_zscores(points)
        max_edge_z = (
            float(np.nanmax(np.abs(edge_zscores))) if len(edge_zscores) > 0 else 0.0
        )
        mean_edge_z = (
            float(np.nanmean(np.abs(edge_zscores))) if len(edge_zscores) > 0 else 0.0
        )

        # Angle features
        angle_zscores = self._compute_angle_zscores(points)
        max_angle_z = (
            float(np.nanmax(np.abs(angle_zscores))) if len(angle_zscores) > 0 else 0.0
        )
        mean_angle_z = (
            float(np.nanmean(np.abs(angle_zscores))) if len(angle_zscores) > 0 else 0.0
        )

        # Pairwise features
        pairwise_zscores = self._compute_pairwise_zscores(points)
        max_pw_z = (
            float(np.nanmax(np.abs(pairwise_zscores)))
            if len(pairwise_zscores) > 0
            else 0.0
        )
        mean_pw_z = (
            float(np.nanmean(np.abs(pairwise_zscores)))
            if len(pairwise_zscores) > 0
            else 0.0
        )

        # Bbox features
        bbox_z = self._compute_bbox_zscore(points)

        # Isolation features
        centroid_dists = self._compute_centroid_distances(points)
        max_cent_dist = (
            float(np.nanmax(centroid_dists)) if len(centroid_dists) > 0 else 0.0
        )
        cent_dist_std = (
            float(np.nanstd(centroid_dists)) if len(centroid_dists) > 0 else 0.0
        )

        # Symmetry features
        min_sym = self._compute_symmetry_consistency(points)

        # Visibility features
        vis_rate, has_isolated_inv = self._compute_visibility_features(points)

        return np.array(
            [
                max_edge_z,
                mean_edge_z,
                max_angle_z,
                mean_angle_z,
                max_pw_z,
                mean_pw_z,
                bbox_z,
                max_cent_dist,
                cent_dist_std,
                min_sym,
                vis_rate,
                1.0 if has_isolated_inv else 0.0,
            ]
        )

    def _compute_edge_zscores(self, points: np.ndarray) -> np.ndarray:
        """Compute edge length z-scores."""
        zscores = []
        for src, dst in self.edges:
            p1, p2 = points[src], points[dst]
            if np.isnan(p1).any() or np.isnan(p2).any():
                continue

            length = np.linalg.norm(p2 - p1)
            key = tuple(sorted([src, dst]))
            if key in self.stats.edge_means:
                z = (length - self.stats.edge_means[key]) / self.stats.edge_stds[key]
                zscores.append(z)

        return np.array(zscores)

    def _compute_angle_zscores(self, points: np.ndarray) -> np.ndarray:
        """Compute joint angle z-scores."""
        zscores = []
        for center, neighbors in self._adjacency.items():
            if len(neighbors) < 2:
                continue
            pc = points[center]
            if np.isnan(pc).any():
                continue

            for i, n1 in enumerate(neighbors):
                for n2 in neighbors[i + 1 :]:
                    p1 = points[n1]
                    p2 = points[n2]
                    if np.isnan(p1).any() or np.isnan(p2).any():
                        continue

                    v1 = p1 - pc
                    v2 = p2 - pc
                    norm1 = np.linalg.norm(v1)
                    norm2 = np.linalg.norm(v2)
                    if norm1 < 1e-6 or norm2 < 1e-6:
                        continue

                    cos_angle = np.clip(np.dot(v1, v2) / (norm1 * norm2), -1.0, 1.0)
                    angle = np.arccos(cos_angle)

                    key = (center, min(n1, n2), max(n1, n2))
                    if key in self.stats.angle_means:
                        z = (
                            angle - self.stats.angle_means[key]
                        ) / self.stats.angle_stds[key]
                        zscores.append(z)

        return np.array(zscores)

    def _compute_pairwise_zscores(self, points: np.ndarray) -> np.ndarray:
        """Compute pairwise distance z-scores."""
        zscores = []
        for i in range(self.n_nodes):
            for j in range(i + 1, self.n_nodes):
                p1, p2 = points[i], points[j]
                if np.isnan(p1).any() or np.isnan(p2).any():
                    continue

                dist = np.linalg.norm(p2 - p1)
                key = (i, j)
                if key in self.stats.pairwise_means:
                    z = (
                        dist - self.stats.pairwise_means[key]
                    ) / self.stats.pairwise_stds[key]
                    zscores.append(z)

        return np.array(zscores)

    def _compute_bbox_zscore(self, points: np.ndarray) -> float:
        """Compute bounding box area z-score."""
        visible = points[~np.isnan(points[:, 0])]
        if len(visible) < 2:
            return 0.0

        min_xy = visible.min(axis=0)
        max_xy = visible.max(axis=0)
        area = (max_xy[0] - min_xy[0]) * (max_xy[1] - min_xy[1])
        return (area - self.stats.bbox_area_mean) / self.stats.bbox_area_std

    def _compute_centroid_distances(self, points: np.ndarray) -> np.ndarray:
        """Compute distance from each node to centroid."""
        visible_mask = ~np.isnan(points[:, 0])
        if visible_mask.sum() < 2:
            return np.array([])

        visible_pts = points[visible_mask]
        centroid = visible_pts.mean(axis=0)

        distances = []
        for i in range(self.n_nodes):
            if visible_mask[i]:
                distances.append(np.linalg.norm(points[i] - centroid))

        return np.array(distances)

    def _compute_symmetry_consistency(self, points: np.ndarray) -> float:
        """Compute minimum symmetry consistency score."""
        if len(self.symmetry_pairs) < 2:
            return 1.0  # No symmetry, assume consistent

        consistency_scores = []
        for i, (l1, r1) in enumerate(self.symmetry_pairs):
            p_l1 = points[l1]
            p_r1 = points[r1]
            if np.isnan(p_l1).any() or np.isnan(p_r1).any():
                continue

            consistent_count = 0
            total_count = 0

            for j, (l2, r2) in enumerate(self.symmetry_pairs):
                if i == j:
                    continue
                p_l2 = points[l2]
                p_r2 = points[r2]
                if np.isnan(p_l2).any() or np.isnan(p_r2).any():
                    continue

                dist_ll = np.linalg.norm(p_l1 - p_l2)
                dist_lr = np.linalg.norm(p_l1 - p_r2)
                ratio = dist_ll / max(dist_lr, 1e-6)

                if ratio < 0.9:
                    consistent_count += 1
                elif ratio <= 1.1:
                    consistent_count += 0.5
                total_count += 1

            if total_count > 0:
                consistency_scores.append(consistent_count / total_count)

        return float(np.min(consistency_scores)) if consistency_scores else 1.0

    def _compute_visibility_features(self, points: np.ndarray) -> tuple[float, bool]:
        """Compute visibility rate and isolated invisible flag."""
        visible_mask = ~np.isnan(points[:, 0])
        vis_rate = visible_mask.sum() / self.n_nodes

        # Check for isolated invisible nodes
        has_isolated_inv = False
        for i in range(self.n_nodes):
            if visible_mask[i]:
                continue
            neighbors = self._adjacency[i]
            if neighbors and all(visible_mask[n] for n in neighbors):
                has_isolated_inv = True
                break

        return vis_rate, has_isolated_inv

__init__(edges, n_nodes, symmetry_pairs=None)

Initialize extractor.

Parameters:

Name Type Description Default
edges list[tuple[int, int]]

List of edge tuples (src_idx, dst_idx).

required
n_nodes int

Number of nodes in skeleton.

required
symmetry_pairs Optional[list[tuple[int, int]]]

Optional list of symmetric node pairs.

None
Source code in sleap/qc/features/baseline.py
def __init__(
    self,
    edges: list[tuple[int, int]],
    n_nodes: int,
    symmetry_pairs: Optional[list[tuple[int, int]]] = None,
):
    """Initialize extractor.

    Args:
        edges: List of edge tuples (src_idx, dst_idx).
        n_nodes: Number of nodes in skeleton.
        symmetry_pairs: Optional list of symmetric node pairs.
    """
    self.edges = edges
    self.n_nodes = n_nodes
    self.symmetry_pairs = symmetry_pairs or []
    self.stats: Optional[DatasetStats] = None
    self._adjacency: Optional[dict[int, list[int]]] = None

extract(points)

Extract feature vector from a single instance.

Parameters:

Name Type Description Default
points ndarray

(n_nodes, 2) array of pose coordinates.

required

Returns:

Type Description
ndarray

(n_features,) feature vector.

Source code in sleap/qc/features/baseline.py
def extract(self, points: np.ndarray) -> np.ndarray:
    """Extract feature vector from a single instance.

    Args:
        points: (n_nodes, 2) array of pose coordinates.

    Returns:
        (n_features,) feature vector.
    """
    if self.stats is None:
        raise ValueError("Must call fit() before extract()")

    # Edge features
    edge_zscores = self._compute_edge_zscores(points)
    max_edge_z = (
        float(np.nanmax(np.abs(edge_zscores))) if len(edge_zscores) > 0 else 0.0
    )
    mean_edge_z = (
        float(np.nanmean(np.abs(edge_zscores))) if len(edge_zscores) > 0 else 0.0
    )

    # Angle features
    angle_zscores = self._compute_angle_zscores(points)
    max_angle_z = (
        float(np.nanmax(np.abs(angle_zscores))) if len(angle_zscores) > 0 else 0.0
    )
    mean_angle_z = (
        float(np.nanmean(np.abs(angle_zscores))) if len(angle_zscores) > 0 else 0.0
    )

    # Pairwise features
    pairwise_zscores = self._compute_pairwise_zscores(points)
    max_pw_z = (
        float(np.nanmax(np.abs(pairwise_zscores)))
        if len(pairwise_zscores) > 0
        else 0.0
    )
    mean_pw_z = (
        float(np.nanmean(np.abs(pairwise_zscores)))
        if len(pairwise_zscores) > 0
        else 0.0
    )

    # Bbox features
    bbox_z = self._compute_bbox_zscore(points)

    # Isolation features
    centroid_dists = self._compute_centroid_distances(points)
    max_cent_dist = (
        float(np.nanmax(centroid_dists)) if len(centroid_dists) > 0 else 0.0
    )
    cent_dist_std = (
        float(np.nanstd(centroid_dists)) if len(centroid_dists) > 0 else 0.0
    )

    # Symmetry features
    min_sym = self._compute_symmetry_consistency(points)

    # Visibility features
    vis_rate, has_isolated_inv = self._compute_visibility_features(points)

    return np.array(
        [
            max_edge_z,
            mean_edge_z,
            max_angle_z,
            mean_angle_z,
            max_pw_z,
            mean_pw_z,
            bbox_z,
            max_cent_dist,
            cent_dist_std,
            min_sym,
            vis_rate,
            1.0 if has_isolated_inv else 0.0,
        ]
    )

fit(instances)

Compute statistics from reference instances.

Parameters:

Name Type Description Default
instances list[ndarray]

List of (n_nodes, 2) arrays of pose coordinates. NaN values indicate invisible nodes.

required

Returns:

Type Description
'BaselineFeatureExtractor'

Self for chaining.

Source code in sleap/qc/features/baseline.py
def fit(self, instances: list[np.ndarray]) -> "BaselineFeatureExtractor":
    """Compute statistics from reference instances.

    Args:
        instances: List of (n_nodes, 2) arrays of pose coordinates.
            NaN values indicate invisible nodes.

    Returns:
        Self for chaining.
    """
    # Build adjacency
    self._adjacency = {i: [] for i in range(self.n_nodes)}
    for src, dst in self.edges:
        self._adjacency[src].append(dst)
        self._adjacency[dst].append(src)

    # Collect edge lengths
    edge_lengths: dict[tuple[int, int], list[float]] = {
        tuple(sorted(e)): [] for e in self.edges
    }
    for points in instances:
        for src, dst in self.edges:
            p1, p2 = points[src], points[dst]
            if not (np.isnan(p1).any() or np.isnan(p2).any()):
                key = tuple(sorted([src, dst]))
                edge_lengths[key].append(np.linalg.norm(p2 - p1))

    # Collect pairwise distances
    pairwise_dists: dict[tuple[int, int], list[float]] = {}
    for i in range(self.n_nodes):
        for j in range(i + 1, self.n_nodes):
            pairwise_dists[(i, j)] = []

    for points in instances:
        for i in range(self.n_nodes):
            for j in range(i + 1, self.n_nodes):
                p1, p2 = points[i], points[j]
                if not (np.isnan(p1).any() or np.isnan(p2).any()):
                    pairwise_dists[(i, j)].append(np.linalg.norm(p2 - p1))

    # Collect joint angles
    angle_values: dict[tuple[int, int, int], list[float]] = {}
    for center, neighbors in self._adjacency.items():
        if len(neighbors) < 2:
            continue
        for i, n1 in enumerate(neighbors):
            for n2 in neighbors[i + 1 :]:
                key = (center, min(n1, n2), max(n1, n2))
                angle_values[key] = []

    for points in instances:
        for center, neighbors in self._adjacency.items():
            if len(neighbors) < 2:
                continue
            pc = points[center]
            if np.isnan(pc).any():
                continue

            for i, n1 in enumerate(neighbors):
                for n2 in neighbors[i + 1 :]:
                    p1 = points[n1]
                    p2 = points[n2]
                    if np.isnan(p1).any() or np.isnan(p2).any():
                        continue

                    v1 = p1 - pc
                    v2 = p2 - pc
                    norm1 = np.linalg.norm(v1)
                    norm2 = np.linalg.norm(v2)
                    if norm1 < 1e-6 or norm2 < 1e-6:
                        continue

                    cos_angle = np.clip(np.dot(v1, v2) / (norm1 * norm2), -1.0, 1.0)
                    angle = np.arccos(cos_angle)

                    key = (center, min(n1, n2), max(n1, n2))
                    angle_values[key].append(angle)

    # Collect bbox areas
    bbox_areas = []
    for points in instances:
        visible = points[~np.isnan(points[:, 0])]
        if len(visible) >= 2:
            min_xy = visible.min(axis=0)
            max_xy = visible.max(axis=0)
            area = (max_xy[0] - min_xy[0]) * (max_xy[1] - min_xy[1])
            bbox_areas.append(area)

    # Compute statistics
    def safe_mean(values: list[float]) -> float:
        return float(np.mean(values)) if values else 0.0

    def safe_std(values: list[float], min_val: float = 1e-6) -> float:
        return max(float(np.std(values)) if values else min_val, min_val)

    self.stats = DatasetStats(
        edge_means={k: safe_mean(v) for k, v in edge_lengths.items()},
        edge_stds={k: safe_std(v) for k, v in edge_lengths.items()},
        pairwise_means={k: safe_mean(v) for k, v in pairwise_dists.items()},
        pairwise_stds={k: safe_std(v) for k, v in pairwise_dists.items()},
        angle_means={k: safe_mean(v) for k, v in angle_values.items()},
        angle_stds={k: safe_std(v) for k, v in angle_values.items()},
        bbox_area_mean=safe_mean(bbox_areas),
        bbox_area_std=safe_std(bbox_areas),
    )

    return self

NearestNeighborScorer

Score instances by distance to nearest neighbor in reference set.

Uses KD-tree for efficient O(log n) nearest neighbor queries.

Attributes:

Name Type Description
normalize

Whether to normalize poses before comparison.

method

Distance method ("euclidean" or "procrustes").

reference_poses Optional[ndarray]

Stored reference poses after fitting.

Methods:

Name Description
__init__

Initialize scorer.

fit

Store reference poses and build KD-tree for fast queries.

score

Score a pose by distance to nearest neighbor.

score_batch

Score multiple poses efficiently using KD-tree.

Source code in sleap/qc/features/reference.py
class NearestNeighborScorer:
    """Score instances by distance to nearest neighbor in reference set.

    Uses KD-tree for efficient O(log n) nearest neighbor queries.

    Attributes:
        normalize: Whether to normalize poses before comparison.
        method: Distance method ("euclidean" or "procrustes").
        reference_poses: Stored reference poses after fitting.
    """

    def __init__(self, normalize: bool = True, method: str = "euclidean"):
        """Initialize scorer.

        Args:
            normalize: Whether to normalize poses before comparison.
            method: Distance method.
        """
        self.normalize = normalize
        self.method = method
        self.reference_poses: Optional[np.ndarray] = None
        self._kdtree = None
        self._flattened_refs: Optional[np.ndarray] = None

    def fit(self, poses: np.ndarray) -> "NearestNeighborScorer":
        """Store reference poses and build KD-tree for fast queries.

        Args:
            poses: (N_instances, N_nodes, 2) array of reference poses.

        Returns:
            Self for chaining.
        """
        from sklearn.neighbors import NearestNeighbors

        if self.normalize:
            self.reference_poses = np.array([normalize_pose(p) for p in poses])
        else:
            self.reference_poses = poses.copy()

        # Build KD-tree for fast queries (euclidean method only)
        if self.method == "euclidean":
            # Flatten poses and impute NaN with 0 (0 is near center after norm)
            self._flattened_refs = np.array(
                [np.nan_to_num(p.flatten(), nan=0.0) for p in self.reference_poses]
            )
            self._kdtree = NearestNeighbors(
                n_neighbors=1, algorithm="auto", metric="euclidean"
            )
            self._kdtree.fit(self._flattened_refs)

        return self

    def score(self, pose: np.ndarray) -> dict[str, float]:
        """Score a pose by distance to nearest neighbor.

        Uses KD-tree for fast O(log n) queries when available.

        Args:
            pose: (N_nodes, 2) array.

        Returns:
            Dictionary with:
            - nn_distance: distance to nearest neighbor
            - nn_index: index of nearest neighbor
            - mean_distance: mean distance to all references (only for non-KD-tree)
        """
        if self.reference_poses is None:
            raise ValueError("Model not fitted. Call fit() first.")

        if self.normalize:
            query = normalize_pose(pose)
        else:
            query = pose

        # Fast path: use KD-tree for euclidean distance
        if self._kdtree is not None:
            query_flat = np.nan_to_num(query.flatten(), nan=0.0).reshape(1, -1)
            distances, indices = self._kdtree.kneighbors(query_flat)
            return {
                "nn_distance": float(distances[0, 0]),
                "nn_index": int(indices[0, 0]),
                "mean_distance": float(distances[0, 0]),  # Approximate
            }

        # Slow path: iterate over all references (for procrustes)
        distances = []
        for ref_pose in self.reference_poses:
            dist = pose_distance(query, ref_pose, method=self.method)
            distances.append(dist)

        distances = np.array(distances)
        valid_distances = distances[np.isfinite(distances)]

        if len(valid_distances) == 0:
            return {
                "nn_distance": float("inf"),
                "nn_index": -1,
                "mean_distance": float("inf"),
            }

        nn_idx = int(np.argmin(distances))
        return {
            "nn_distance": float(distances[nn_idx]),
            "nn_index": nn_idx,
            "mean_distance": float(np.mean(valid_distances)),
        }

    def score_batch(self, poses: np.ndarray) -> np.ndarray:
        """Score multiple poses efficiently using KD-tree.

        Args:
            poses: (N_instances, N_nodes, 2) array of poses to score.

        Returns:
            (N_instances,) array of nearest neighbor distances.
        """
        if self.reference_poses is None:
            raise ValueError("Model not fitted. Call fit() first.")

        if self._kdtree is None:
            # Fall back to individual scoring
            return np.array([self.score(p)["nn_distance"] for p in poses])

        # Normalize and flatten all poses
        if self.normalize:
            normalized = np.array([normalize_pose(p) for p in poses])
        else:
            normalized = poses

        flattened = np.array([np.nan_to_num(p.flatten(), nan=0.0) for p in normalized])

        # Batch KD-tree query
        distances, _ = self._kdtree.kneighbors(flattened)
        return distances[:, 0]

__init__(normalize=True, method='euclidean')

Initialize scorer.

Parameters:

Name Type Description Default
normalize bool

Whether to normalize poses before comparison.

True
method str

Distance method.

'euclidean'
Source code in sleap/qc/features/reference.py
def __init__(self, normalize: bool = True, method: str = "euclidean"):
    """Initialize scorer.

    Args:
        normalize: Whether to normalize poses before comparison.
        method: Distance method.
    """
    self.normalize = normalize
    self.method = method
    self.reference_poses: Optional[np.ndarray] = None
    self._kdtree = None
    self._flattened_refs: Optional[np.ndarray] = None

fit(poses)

Store reference poses and build KD-tree for fast queries.

Parameters:

Name Type Description Default
poses ndarray

(N_instances, N_nodes, 2) array of reference poses.

required

Returns:

Type Description
'NearestNeighborScorer'

Self for chaining.

Source code in sleap/qc/features/reference.py
def fit(self, poses: np.ndarray) -> "NearestNeighborScorer":
    """Store reference poses and build KD-tree for fast queries.

    Args:
        poses: (N_instances, N_nodes, 2) array of reference poses.

    Returns:
        Self for chaining.
    """
    from sklearn.neighbors import NearestNeighbors

    if self.normalize:
        self.reference_poses = np.array([normalize_pose(p) for p in poses])
    else:
        self.reference_poses = poses.copy()

    # Build KD-tree for fast queries (euclidean method only)
    if self.method == "euclidean":
        # Flatten poses and impute NaN with 0 (0 is near center after norm)
        self._flattened_refs = np.array(
            [np.nan_to_num(p.flatten(), nan=0.0) for p in self.reference_poses]
        )
        self._kdtree = NearestNeighbors(
            n_neighbors=1, algorithm="auto", metric="euclidean"
        )
        self._kdtree.fit(self._flattened_refs)

    return self

score(pose)

Score a pose by distance to nearest neighbor.

Uses KD-tree for fast O(log n) queries when available.

Parameters:

Name Type Description Default
pose ndarray

(N_nodes, 2) array.

required

Returns:

Type Description
dict[str, float]

Dictionary with: - nn_distance: distance to nearest neighbor - nn_index: index of nearest neighbor - mean_distance: mean distance to all references (only for non-KD-tree)

Source code in sleap/qc/features/reference.py
def score(self, pose: np.ndarray) -> dict[str, float]:
    """Score a pose by distance to nearest neighbor.

    Uses KD-tree for fast O(log n) queries when available.

    Args:
        pose: (N_nodes, 2) array.

    Returns:
        Dictionary with:
        - nn_distance: distance to nearest neighbor
        - nn_index: index of nearest neighbor
        - mean_distance: mean distance to all references (only for non-KD-tree)
    """
    if self.reference_poses is None:
        raise ValueError("Model not fitted. Call fit() first.")

    if self.normalize:
        query = normalize_pose(pose)
    else:
        query = pose

    # Fast path: use KD-tree for euclidean distance
    if self._kdtree is not None:
        query_flat = np.nan_to_num(query.flatten(), nan=0.0).reshape(1, -1)
        distances, indices = self._kdtree.kneighbors(query_flat)
        return {
            "nn_distance": float(distances[0, 0]),
            "nn_index": int(indices[0, 0]),
            "mean_distance": float(distances[0, 0]),  # Approximate
        }

    # Slow path: iterate over all references (for procrustes)
    distances = []
    for ref_pose in self.reference_poses:
        dist = pose_distance(query, ref_pose, method=self.method)
        distances.append(dist)

    distances = np.array(distances)
    valid_distances = distances[np.isfinite(distances)]

    if len(valid_distances) == 0:
        return {
            "nn_distance": float("inf"),
            "nn_index": -1,
            "mean_distance": float("inf"),
        }

    nn_idx = int(np.argmin(distances))
    return {
        "nn_distance": float(distances[nn_idx]),
        "nn_index": nn_idx,
        "mean_distance": float(np.mean(valid_distances)),
    }

score_batch(poses)

Score multiple poses efficiently using KD-tree.

Parameters:

Name Type Description Default
poses ndarray

(N_instances, N_nodes, 2) array of poses to score.

required

Returns:

Type Description
ndarray

(N_instances,) array of nearest neighbor distances.

Source code in sleap/qc/features/reference.py
def score_batch(self, poses: np.ndarray) -> np.ndarray:
    """Score multiple poses efficiently using KD-tree.

    Args:
        poses: (N_instances, N_nodes, 2) array of poses to score.

    Returns:
        (N_instances,) array of nearest neighbor distances.
    """
    if self.reference_poses is None:
        raise ValueError("Model not fitted. Call fit() first.")

    if self._kdtree is None:
        # Fall back to individual scoring
        return np.array([self.score(p)["nn_distance"] for p in poses])

    # Normalize and flatten all poses
    if self.normalize:
        normalized = np.array([normalize_pose(p) for p in poses])
    else:
        normalized = poses

    flattened = np.array([np.nan_to_num(p.flatten(), nan=0.0) for p in normalized])

    # Batch KD-tree query
    distances, _ = self._kdtree.kneighbors(flattened)
    return distances[:, 0]

SkeletonAnalyzer

Analyze skeleton topology to determine feature applicability.

This class extracts structural properties from a skeleton graph that determine which QC features are applicable (e.g., curvature requires chains of 5+ nodes, symmetry requires defined pairs).

Attributes:

Name Type Description
n_nodes

Number of nodes in the skeleton.

n_edges

Number of edges.

edges list[tuple[int, int]]

List of edge tuples (src, dst).

node_names

List of node names.

symmetry_pairs list[tuple[int, int]]

List of symmetric node pairs as (left_idx, right_idx).

spine list[tuple[int, int]]

Longest path through the skeleton (main chain).

all_chains list[tuple[int, int]]

All simple chains of length >= 3.

endpoints list[tuple[int, int]]

Node indices with degree 1.

branch_points list[tuple[int, int]]

Node indices with degree > 2.

max_chain_length list[tuple[int, int]]

Length of the longest chain.

n_triplets list[tuple[int, int]]

Number of joint triplets (for angle features).

Methods:

Name Description
__init__

Initialize from a sleap-io Skeleton.

get_adjacency

Get adjacency list representation.

get_curvature_chains

Get chains suitable for curvature computation.

Source code in sleap/qc/features/skeleton.py
class SkeletonAnalyzer:
    """Analyze skeleton topology to determine feature applicability.

    This class extracts structural properties from a skeleton graph that
    determine which QC features are applicable (e.g., curvature requires
    chains of 5+ nodes, symmetry requires defined pairs).

    Attributes:
        n_nodes: Number of nodes in the skeleton.
        n_edges: Number of edges.
        edges: List of edge tuples (src, dst).
        node_names: List of node names.
        symmetry_pairs: List of symmetric node pairs as (left_idx, right_idx).
        spine: Longest path through the skeleton (main chain).
        all_chains: All simple chains of length >= 3.
        endpoints: Node indices with degree 1.
        branch_points: Node indices with degree > 2.
        max_chain_length: Length of the longest chain.
        n_triplets: Number of joint triplets (for angle features).
    """

    def __init__(self, skeleton: "sio.Skeleton"):
        """Initialize from a sleap-io Skeleton.

        Args:
            skeleton: The skeleton to analyze.
        """
        self.n_nodes = len(skeleton.nodes)
        self.node_names = [n.name for n in skeleton.nodes]

        # Build name -> index mapping
        name_to_idx = {n.name: i for i, n in enumerate(skeleton.nodes)}

        # Extract edges as index pairs
        self.edges: list[tuple[int, int]] = []
        for edge in skeleton.edges:
            src_idx = name_to_idx[edge.source.name]
            dst_idx = name_to_idx[edge.destination.name]
            self.edges.append((src_idx, dst_idx))
        self.n_edges = len(self.edges)

        # Extract symmetry pairs
        self.symmetry_pairs: list[tuple[int, int]] = []
        if skeleton.symmetries:
            for sym in skeleton.symmetries:
                # sym.nodes is a set, convert to list for indexing
                sym_nodes = list(sym.nodes)
                if len(sym_nodes) == 2:
                    left_idx = name_to_idx[sym_nodes[0].name]
                    right_idx = name_to_idx[sym_nodes[1].name]
                    self.symmetry_pairs.append((left_idx, right_idx))

        # Build graph and analyze
        self._graph = self._build_graph()
        self._analyze_structure()

    def _build_graph(self) -> nx.Graph:
        """Build a networkx graph from skeleton edges."""
        G = nx.Graph()
        for i in range(self.n_nodes):
            G.add_node(i, name=self.node_names[i])
        for src, dst in self.edges:
            G.add_edge(src, dst)
        return G

    def _analyze_structure(self) -> None:
        """Analyze skeleton structure."""
        G = self._graph

        # Find endpoints and branch points
        self.endpoints = [n for n in G.nodes() if G.degree(n) == 1]
        self.branch_points = [n for n in G.nodes() if G.degree(n) > 2]

        # Find longest path (spine)
        self.spine = self._find_longest_path()
        self.max_chain_length = len(self.spine)

        # Find all chains
        self.all_chains = self._find_all_chains(min_length=3)

        # Count triplets (for angle features)
        self.n_triplets = self._count_triplets()

    def _find_longest_path(self) -> list[int]:
        """Find the longest simple path in the graph."""
        G = self._graph
        if len(G.nodes()) == 0:
            return []

        endpoints = self.endpoints if self.endpoints else list(G.nodes())[:1]
        longest_path: list[int] = []

        for start in endpoints:
            distances = nx.single_source_shortest_path_length(G, start)
            farthest = max(distances, key=distances.get)
            path = nx.shortest_path(G, start, farthest)
            if len(path) > len(longest_path):
                longest_path = path

        return longest_path

    def _find_all_chains(self, min_length: int = 3) -> list[list[int]]:
        """Find all simple chains in the graph."""
        G = self._graph
        terminators = set(self.endpoints) | set(self.branch_points)
        chains: list[list[int]] = []
        visited_edges: set[tuple[int, int]] = set()

        for start in terminators:
            for neighbor in G.neighbors(start):
                edge = tuple(sorted([start, neighbor]))
                if edge in visited_edges:
                    continue

                # Follow the chain
                chain = [start, neighbor]
                visited_edges.add(edge)

                current = neighbor
                prev = start

                while current not in terminators:
                    neighbors = list(G.neighbors(current))
                    next_nodes = [n for n in neighbors if n != prev]
                    if not next_nodes:
                        break

                    next_node = next_nodes[0]
                    edge = tuple(sorted([current, next_node]))
                    visited_edges.add(edge)
                    chain.append(next_node)
                    prev = current
                    current = next_node

                if len(chain) >= min_length:
                    chains.append(chain)

        return chains

    def _count_triplets(self) -> int:
        """Count number of joint triplets (nodes with 2+ neighbors)."""
        count = 0
        G = self._graph
        for node in G.nodes():
            degree = G.degree(node)
            if degree >= 2:
                # Number of angle pairs at this node
                count += degree * (degree - 1) // 2
        return count

    def get_curvature_chains(self, min_length: int = 3) -> list[list[int]]:
        """Get chains suitable for curvature computation.

        Returns:
            List of chains sorted by length (longest first).
        """
        chains = []
        if len(self.spine) >= min_length:
            chains.append(self.spine)

        spine_set = set(self.spine)
        for chain in self.all_chains:
            if set(chain).issubset(spine_set):
                continue
            if len(chain) >= min_length:
                chains.append(chain)

        chains.sort(key=len, reverse=True)
        return chains

    @property
    def has_symmetry(self) -> bool:
        """Whether skeleton has symmetry pairs defined."""
        return len(self.symmetry_pairs) >= 1

    def get_adjacency(self) -> dict[int, list[int]]:
        """Get adjacency list representation."""
        adjacency: dict[int, list[int]] = {i: [] for i in range(self.n_nodes)}
        for src, dst in self.edges:
            adjacency[src].append(dst)
            adjacency[dst].append(src)
        return adjacency

has_symmetry property

Whether skeleton has symmetry pairs defined.

__init__(skeleton)

Initialize from a sleap-io Skeleton.

Parameters:

Name Type Description Default
skeleton 'sio.Skeleton'

The skeleton to analyze.

required
Source code in sleap/qc/features/skeleton.py
def __init__(self, skeleton: "sio.Skeleton"):
    """Initialize from a sleap-io Skeleton.

    Args:
        skeleton: The skeleton to analyze.
    """
    self.n_nodes = len(skeleton.nodes)
    self.node_names = [n.name for n in skeleton.nodes]

    # Build name -> index mapping
    name_to_idx = {n.name: i for i, n in enumerate(skeleton.nodes)}

    # Extract edges as index pairs
    self.edges: list[tuple[int, int]] = []
    for edge in skeleton.edges:
        src_idx = name_to_idx[edge.source.name]
        dst_idx = name_to_idx[edge.destination.name]
        self.edges.append((src_idx, dst_idx))
    self.n_edges = len(self.edges)

    # Extract symmetry pairs
    self.symmetry_pairs: list[tuple[int, int]] = []
    if skeleton.symmetries:
        for sym in skeleton.symmetries:
            # sym.nodes is a set, convert to list for indexing
            sym_nodes = list(sym.nodes)
            if len(sym_nodes) == 2:
                left_idx = name_to_idx[sym_nodes[0].name]
                right_idx = name_to_idx[sym_nodes[1].name]
                self.symmetry_pairs.append((left_idx, right_idx))

    # Build graph and analyze
    self._graph = self._build_graph()
    self._analyze_structure()

get_adjacency()

Get adjacency list representation.

Source code in sleap/qc/features/skeleton.py
def get_adjacency(self) -> dict[int, list[int]]:
    """Get adjacency list representation."""
    adjacency: dict[int, list[int]] = {i: [] for i in range(self.n_nodes)}
    for src, dst in self.edges:
        adjacency[src].append(dst)
        adjacency[dst].append(src)
    return adjacency

get_curvature_chains(min_length=3)

Get chains suitable for curvature computation.

Returns:

Type Description
list[list[int]]

List of chains sorted by length (longest first).

Source code in sleap/qc/features/skeleton.py
def get_curvature_chains(self, min_length: int = 3) -> list[list[int]]:
    """Get chains suitable for curvature computation.

    Returns:
        List of chains sorted by length (longest first).
    """
    chains = []
    if len(self.spine) >= min_length:
        chains.append(self.spine)

    spine_set = set(self.spine)
    for chain in self.all_chains:
        if set(chain).issubset(spine_set):
            continue
        if len(chain) >= min_length:
            chains.append(chain)

    chains.sort(key=len, reverse=True)
    return chains

VisibilityModel

Learn and score visibility patterns.

Learns which nodes tend to be visible together, then flags instances where the visibility pattern is unusual (e.g., hip visible but knee invisible when they're usually both visible or both invisible).

Attributes:

Name Type Description
n_nodes int

Number of nodes in skeleton.

co_visibility_matrix Optional[ndarray]

P(node_j visible | node_i visible).

visibility_rates Optional[ndarray]

Per-node visibility rates.

n_instances int

Number of instances used for fitting.

Methods:

Name Description
__init__

Initialize the visibility model.

fit

Learn co-visibility patterns from data.

get_expected_visibility

Given some visible nodes, predict expected visibility of others.

score

Score how unusual a visibility pattern is.

Source code in sleap/qc/features/visibility.py
class VisibilityModel:
    """Learn and score visibility patterns.

    Learns which nodes tend to be visible together, then flags instances
    where the visibility pattern is unusual (e.g., hip visible but knee invisible
    when they're usually both visible or both invisible).

    Attributes:
        n_nodes: Number of nodes in skeleton.
        co_visibility_matrix: P(node_j visible | node_i visible).
        visibility_rates: Per-node visibility rates.
        n_instances: Number of instances used for fitting.
    """

    def __init__(self):
        """Initialize the visibility model."""
        self.n_nodes: int = 0
        self.co_visibility_matrix: Optional[np.ndarray] = None
        self.visibility_rates: Optional[np.ndarray] = None
        self.n_instances: int = 0

    def fit(self, visibility_masks: np.ndarray) -> "VisibilityModel":
        """Learn co-visibility patterns from data.

        Args:
            visibility_masks: (N_instances, N_nodes) boolean array.
                True = visible, False = invisible.

        Returns:
            Self for chaining.
        """
        visibility_masks = np.asarray(visibility_masks, dtype=bool)
        self.n_instances, self.n_nodes = visibility_masks.shape

        # Per-node visibility rate
        self.visibility_rates = visibility_masks.mean(axis=0)

        # Co-visibility matrix: P(node_j visible | node_i visible)
        self.co_visibility_matrix = np.zeros((self.n_nodes, self.n_nodes))

        for i in range(self.n_nodes):
            mask_i = visibility_masks[:, i]
            n_visible_i = mask_i.sum()

            if n_visible_i > 0:
                for j in range(self.n_nodes):
                    self.co_visibility_matrix[i, j] = (
                        visibility_masks[mask_i, j].sum() / n_visible_i
                    )

        return self

    def score(self, visibility_mask: np.ndarray) -> dict[str, float]:
        """Score how unusual a visibility pattern is.

        Args:
            visibility_mask: (N_nodes,) boolean array.

        Returns:
            Dictionary with:
            - pattern_score: overall unusualness (0 = normal, 1 = very unusual)
            - n_violations: count of strong violations
        """
        if self.co_visibility_matrix is None:
            raise ValueError("Model not fitted. Call fit() first.")

        visibility_mask = np.asarray(visibility_mask, dtype=bool)
        violations = []

        for i in range(self.n_nodes):
            if not visibility_mask[i]:
                continue

            for j in range(self.n_nodes):
                if i == j:
                    continue

                expected_prob = self.co_visibility_matrix[i, j]

                # Check for violations
                if not visibility_mask[j] and expected_prob > 0.9:
                    # Node j invisible when it should be visible
                    violations.append((i, j, expected_prob))
                elif visibility_mask[j] and expected_prob < 0.1:
                    # Node j visible when it's rarely visible with i
                    violations.append((i, j, expected_prob))

        n_violations = len(violations)
        pattern_score = min(1.0, n_violations / max(1, self.n_nodes))

        return {
            "pattern_score": pattern_score,
            "n_violations": n_violations,
        }

    def get_expected_visibility(self, partial_mask: np.ndarray) -> np.ndarray:
        """Given some visible nodes, predict expected visibility of others.

        Args:
            partial_mask: (N_nodes,) boolean array with some nodes marked visible.

        Returns:
            (N_nodes,) array of expected visibility probabilities.
        """
        if self.co_visibility_matrix is None:
            raise ValueError("Model not fitted. Call fit() first.")

        partial_mask = np.asarray(partial_mask, dtype=bool)
        visible_indices = np.where(partial_mask)[0]

        if len(visible_indices) == 0:
            return self.visibility_rates.copy()

        # Average co-visibility from all visible nodes
        expected = np.zeros(self.n_nodes)
        for i in visible_indices:
            expected += self.co_visibility_matrix[i]
        expected /= len(visible_indices)

        return expected

__init__()

Initialize the visibility model.

Source code in sleap/qc/features/visibility.py
def __init__(self):
    """Initialize the visibility model."""
    self.n_nodes: int = 0
    self.co_visibility_matrix: Optional[np.ndarray] = None
    self.visibility_rates: Optional[np.ndarray] = None
    self.n_instances: int = 0

fit(visibility_masks)

Learn co-visibility patterns from data.

Parameters:

Name Type Description Default
visibility_masks ndarray

(N_instances, N_nodes) boolean array. True = visible, False = invisible.

required

Returns:

Type Description
'VisibilityModel'

Self for chaining.

Source code in sleap/qc/features/visibility.py
def fit(self, visibility_masks: np.ndarray) -> "VisibilityModel":
    """Learn co-visibility patterns from data.

    Args:
        visibility_masks: (N_instances, N_nodes) boolean array.
            True = visible, False = invisible.

    Returns:
        Self for chaining.
    """
    visibility_masks = np.asarray(visibility_masks, dtype=bool)
    self.n_instances, self.n_nodes = visibility_masks.shape

    # Per-node visibility rate
    self.visibility_rates = visibility_masks.mean(axis=0)

    # Co-visibility matrix: P(node_j visible | node_i visible)
    self.co_visibility_matrix = np.zeros((self.n_nodes, self.n_nodes))

    for i in range(self.n_nodes):
        mask_i = visibility_masks[:, i]
        n_visible_i = mask_i.sum()

        if n_visible_i > 0:
            for j in range(self.n_nodes):
                self.co_visibility_matrix[i, j] = (
                    visibility_masks[mask_i, j].sum() / n_visible_i
                )

    return self

get_expected_visibility(partial_mask)

Given some visible nodes, predict expected visibility of others.

Parameters:

Name Type Description Default
partial_mask ndarray

(N_nodes,) boolean array with some nodes marked visible.

required

Returns:

Type Description
ndarray

(N_nodes,) array of expected visibility probabilities.

Source code in sleap/qc/features/visibility.py
def get_expected_visibility(self, partial_mask: np.ndarray) -> np.ndarray:
    """Given some visible nodes, predict expected visibility of others.

    Args:
        partial_mask: (N_nodes,) boolean array with some nodes marked visible.

    Returns:
        (N_nodes,) array of expected visibility probabilities.
    """
    if self.co_visibility_matrix is None:
        raise ValueError("Model not fitted. Call fit() first.")

    partial_mask = np.asarray(partial_mask, dtype=bool)
    visible_indices = np.where(partial_mask)[0]

    if len(visible_indices) == 0:
        return self.visibility_rates.copy()

    # Average co-visibility from all visible nodes
    expected = np.zeros(self.n_nodes)
    for i in visible_indices:
        expected += self.co_visibility_matrix[i]
    expected /= len(visible_indices)

    return expected

score(visibility_mask)

Score how unusual a visibility pattern is.

Parameters:

Name Type Description Default
visibility_mask ndarray

(N_nodes,) boolean array.

required

Returns:

Type Description
dict[str, float]

Dictionary with: - pattern_score: overall unusualness (0 = normal, 1 = very unusual) - n_violations: count of strong violations

Source code in sleap/qc/features/visibility.py
def score(self, visibility_mask: np.ndarray) -> dict[str, float]:
    """Score how unusual a visibility pattern is.

    Args:
        visibility_mask: (N_nodes,) boolean array.

    Returns:
        Dictionary with:
        - pattern_score: overall unusualness (0 = normal, 1 = very unusual)
        - n_violations: count of strong violations
    """
    if self.co_visibility_matrix is None:
        raise ValueError("Model not fitted. Call fit() first.")

    visibility_mask = np.asarray(visibility_mask, dtype=bool)
    violations = []

    for i in range(self.n_nodes):
        if not visibility_mask[i]:
            continue

        for j in range(self.n_nodes):
            if i == j:
                continue

            expected_prob = self.co_visibility_matrix[i, j]

            # Check for violations
            if not visibility_mask[j] and expected_prob > 0.9:
                # Node j invisible when it should be visible
                violations.append((i, j, expected_prob))
            elif visibility_mask[j] and expected_prob < 0.1:
                # Node j visible when it's rarely visible with i
                violations.append((i, j, expected_prob))

    n_violations = len(violations)
    pattern_score = min(1.0, n_violations / max(1, self.n_nodes))

    return {
        "pattern_score": pattern_score,
        "n_violations": n_violations,
    }

compute_convex_hull(points)

Compute convex hull metrics for pose compactness.

Parameters:

Name Type Description Default
points ndarray

(N, 2) array of node coordinates (NaN for invisible).

required

Returns:

Type Description
dict[str, float]

Dictionary with: - hull_area: area of convex hull - hull_perimeter: perimeter of convex hull - hull_aspect_ratio: width/height of hull bounding box - compactness: 4*pi*area / perimeter^2 (1 = circle) - n_hull_points: number of points on hull

Source code in sleap/qc/features/structural.py
def compute_convex_hull(
    points: np.ndarray,
) -> dict[str, float]:
    """Compute convex hull metrics for pose compactness.

    Args:
        points: (N, 2) array of node coordinates (NaN for invisible).

    Returns:
        Dictionary with:
        - hull_area: area of convex hull
        - hull_perimeter: perimeter of convex hull
        - hull_aspect_ratio: width/height of hull bounding box
        - compactness: 4*pi*area / perimeter^2 (1 = circle)
        - n_hull_points: number of points on hull
    """
    from scipy.spatial import ConvexHull

    # Filter to visible points
    visible_mask = ~np.isnan(points).any(axis=1)
    visible_points = points[visible_mask]

    if len(visible_points) < 3:
        return {
            "hull_area": 0.0,
            "hull_perimeter": 0.0,
            "hull_aspect_ratio": 1.0,
            "compactness": 0.0,
            "n_hull_points": len(visible_points),
        }

    try:
        hull = ConvexHull(visible_points)
        area = hull.volume  # In 2D, volume = area
        perimeter = hull.area  # In 2D, area = perimeter

        # Aspect ratio from bounding box
        hull_points = visible_points[hull.vertices]
        min_pt = hull_points.min(axis=0)
        max_pt = hull_points.max(axis=0)
        width = max_pt[0] - min_pt[0]
        height = max_pt[1] - min_pt[1]
        aspect_ratio = width / height if height > 0 else 1.0

        # Compactness (isoperimetric quotient)
        compactness = 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0.0

        return {
            "hull_area": float(area),
            "hull_perimeter": float(perimeter),
            "hull_aspect_ratio": float(aspect_ratio),
            "compactness": float(compactness),
            "n_hull_points": len(hull.vertices),
        }

    except Exception:
        # Hull computation can fail for degenerate cases
        return {
            "hull_area": 0.0,
            "hull_perimeter": 0.0,
            "hull_aspect_ratio": 1.0,
            "compactness": 0.0,
            "n_hull_points": 0,
        }

compute_curvature(points, chain)

Compute curvature along a chain of nodes (e.g., spine).

Curvature at each interior node is computed from the angle formed by adjacent edges. High curvature = sharp bend.

Parameters:

Name Type Description Default
points ndarray

(N, 2) array of node coordinates.

required
chain list[int]

Ordered list of node indices forming a chain.

required

Returns:

Type Description
dict[str, float]

Dictionary with: - curvatures: array of curvature values at each interior node - max_curvature: maximum absolute curvature - mean_curvature: mean absolute curvature - curvature_std: standard deviation of curvature - sign_changes: number of curvature sign changes (wiggliness)

Source code in sleap/qc/features/structural.py
def compute_curvature(
    points: np.ndarray,
    chain: list[int],
) -> dict[str, float]:
    """Compute curvature along a chain of nodes (e.g., spine).

    Curvature at each interior node is computed from the angle formed
    by adjacent edges. High curvature = sharp bend.

    Args:
        points: (N, 2) array of node coordinates.
        chain: Ordered list of node indices forming a chain.

    Returns:
        Dictionary with:
        - curvatures: array of curvature values at each interior node
        - max_curvature: maximum absolute curvature
        - mean_curvature: mean absolute curvature
        - curvature_std: standard deviation of curvature
        - sign_changes: number of curvature sign changes (wiggliness)
    """
    if len(chain) < 3:
        return {
            "curvatures": np.array([]),
            "max_curvature": 0.0,
            "mean_curvature": 0.0,
            "curvature_std": 0.0,
            "sign_changes": 0,
        }

    curvatures = []
    for i in range(1, len(chain) - 1):
        prev_idx, curr_idx, next_idx = chain[i - 1], chain[i], chain[i + 1]

        # Skip if any node is invisible
        if (
            np.isnan(points[prev_idx]).any()
            or np.isnan(points[curr_idx]).any()
            or np.isnan(points[next_idx]).any()
        ):
            curvatures.append(np.nan)
            continue

        # Vectors from current to neighbors
        v1 = points[prev_idx] - points[curr_idx]
        v2 = points[next_idx] - points[curr_idx]

        # Angle between vectors (curvature proxy)
        norm1 = np.linalg.norm(v1)
        norm2 = np.linalg.norm(v2)
        if norm1 < 1e-8 or norm2 < 1e-8:
            curvatures.append(np.nan)
            continue

        cos_angle = np.dot(v1, v2) / (norm1 * norm2)
        cos_angle = np.clip(cos_angle, -1, 1)
        angle = np.arccos(cos_angle)

        # Curvature = pi - angle (0 = straight, pi = folded back)
        curvature = np.pi - angle

        # Signed curvature (cross product sign)
        cross = v1[0] * v2[1] - v1[1] * v2[0]
        curvature = curvature * np.sign(cross) if cross != 0 else curvature

        curvatures.append(curvature)

    curvatures = np.array(curvatures)
    valid_curvatures = curvatures[~np.isnan(curvatures)]

    # Count sign changes
    sign_changes = 0
    if len(valid_curvatures) > 1:
        signs = np.sign(valid_curvatures)
        sign_changes = int(np.sum(signs[1:] != signs[:-1]))

    return {
        "curvatures": curvatures,
        "max_curvature": (
            float(np.max(np.abs(valid_curvatures)))
            if len(valid_curvatures) > 0
            else 0.0
        ),
        "mean_curvature": (
            float(np.mean(np.abs(valid_curvatures)))
            if len(valid_curvatures) > 0
            else 0.0
        ),
        "curvature_std": (
            float(np.std(valid_curvatures)) if len(valid_curvatures) > 0 else 0.0
        ),
        "sign_changes": sign_changes,
    }

normalize_pose(points)

Normalize pose to unit scale and center.

Parameters:

Name Type Description Default
points ndarray

(N_nodes, 2) array of coordinates (may contain NaN).

required

Returns:

Type Description
ndarray

Normalized points array (NaN preserved).

Source code in sleap/qc/features/reference.py
def normalize_pose(points: np.ndarray) -> np.ndarray:
    """Normalize pose to unit scale and center.

    Args:
        points: (N_nodes, 2) array of coordinates (may contain NaN).

    Returns:
        Normalized points array (NaN preserved).
    """
    visible_mask = ~np.isnan(points).any(axis=1)
    if visible_mask.sum() < 2:
        return points.copy()

    visible_points = points[visible_mask]

    # Center
    centroid = visible_points.mean(axis=0)

    # Scale by bounding box diagonal
    bbox_min = visible_points.min(axis=0)
    bbox_max = visible_points.max(axis=0)
    scale = np.linalg.norm(bbox_max - bbox_min)
    if scale < 1e-6:
        scale = 1.0

    normalized = (points - centroid) / scale
    return normalized