Skip to content

pose_split

sleap.qc.features.pose_split

Pose-split (chimera) features.

A chimera is a single labeled instance whose keypoints actually span two different animals: e.g. the head/thorax of animal A connected to the abdomen of animal B. This typically shows up as a skeleton that is internally consistent in two tight clusters joined by a single, abnormally stretched "bridging" edge.

This module provides a pure function :func:compute_pose_split that scores how chimera-like a single pose is. It is deliberately argument-driven (points, adjacency, learned edge-length stats) so it can be unit-tested in isolation and so the detector's already-computed baseline statistics can be reused.

The primary signal is graph-based:

  1. On the subgraph induced by the visible nodes, find the connected bridging edge whose normalized length z-score z = (len - mean) / std is largest.
  2. Cut that edge. If the visible subgraph splits cleanly into two components, measure how balanced the split is (split_ratio = smaller / total).
  3. Measure how far apart the two clusters sit relative to their own internal spread (gap_ratio). A real chimera has two compact clusters separated by a wide gap.

A high split_score requires all three (large bridging z, balanced split, large gap). This gating is what keeps it from firing on a normal pose or on a partially-occluded single animal (whose visible subgraph is merely disconnected without an abnormally long bridging edge).

For skeletons where the graph signal is weak or unavailable (star skeletons, very short skeletons, or missing adjacency), a skeleton-agnostic 2-means bimodality :func:compute_pose_split_fallback is used instead.

Functions:

Name Description
compute_pose_split

Score how chimera-like a single pose is (one instance, two animals).

compute_pose_split_fallback

Skeleton-agnostic 2-means bimodality fallback.

compute_pose_split(points, adjacency, edge_means, edge_stds, min_visible=MIN_VISIBLE_NODES)

Score how chimera-like a single pose is (one instance, two animals).

Operates on the subgraph induced by the visible nodes:

  1. Bridging edge. Among visible edges with learned length stats, find the one with the largest normalized stretch z = (len - mean) / std.
  2. Balanced split. Remove that edge; if the visible subgraph splits into exactly two components, split_ratio = smaller / total measures balance (ideal ~0.3-0.5).
  3. Gap. gap_ratio = ||centroidA - centroidB|| / max(spread_A, spread_B) measures how far the clusters sit relative to their own internal spread.

The returned split_score is the gated product of these signals, so it is only high when a long bridging edge separates two balanced, well-spaced clusters. This is what prevents false positives on:

  • a normal pose (no bridging edge with a large z; small gap_ratio),
  • a partially occluded single animal (the visible subgraph may be disconnected, but there is no abnormally stretched bridging edge joining two balanced clusters), and
  • two genuinely close, correctly-merged animals (small gap relative to spread).

If adjacency is unavailable, or the skeleton is too star-like / short for a bridging edge to be meaningful, falls back to :func:compute_pose_split_fallback (2-means bimodality).

Parameters:

Name Type Description Default
points ndarray

(N_nodes, 2) coordinate array (NaN for invisible nodes).

required
adjacency Optional[dict[int, list[int]]]

Maps node_index -> list of neighbor indices (skeleton edges). Pass SkeletonAnalyzer.get_adjacency(). If None, the geometry-only fallback is used.

required
edge_means dict[tuple[int, int], float]

Maps sorted (i, j) edge tuples -> learned mean edge length (reuse DatasetStats.edge_means).

required
edge_stds dict[tuple[int, int], float]

Maps sorted (i, j) edge tuples -> learned edge-length std (reuse DatasetStats.edge_stds). Values should be floored away from zero by the caller (the baseline extractor already does this).

required
min_visible int

Minimum visible nodes required to attempt a split.

MIN_VISIBLE_NODES

Returns:

Type Description
dict[str, float]

Dictionary with:

  • split_score: non-negative chimera score (0 = not a chimera; the integration layer thresholds this, e.g. on the training distribution).
  • split_ratio: smaller-cluster fraction of visible nodes (0..0.5).
  • gap_ratio: inter-cluster distance / max intra-cluster spread.
Source code in sleap/qc/features/pose_split.py
def compute_pose_split(
    points: np.ndarray,
    adjacency: Optional[dict[int, list[int]]],
    edge_means: dict[tuple[int, int], float],
    edge_stds: dict[tuple[int, int], float],
    min_visible: int = MIN_VISIBLE_NODES,
) -> dict[str, float]:
    """Score how *chimera-like* a single pose is (one instance, two animals).

    Operates on the subgraph induced by the visible nodes:

    1. **Bridging edge.** Among visible edges with learned length stats, find
       the one with the largest normalized stretch ``z = (len - mean) / std``.
    2. **Balanced split.** Remove that edge; if the visible subgraph splits into
       exactly two components, ``split_ratio = smaller / total`` measures
       balance (ideal ~0.3-0.5).
    3. **Gap.** ``gap_ratio = ||centroidA - centroidB|| / max(spread_A, spread_B)``
       measures how far the clusters sit relative to their own internal spread.

    The returned ``split_score`` is the *gated product* of these signals, so it
    is only high when a long bridging edge separates two balanced, well-spaced
    clusters. This is what prevents false positives on:

    - a **normal pose** (no bridging edge with a large z; small gap_ratio),
    - a **partially occluded** single animal (the visible subgraph may be
      disconnected, but there is no abnormally stretched bridging edge joining
      two balanced clusters), and
    - two genuinely close, correctly-merged animals (small gap relative to
      spread).

    If adjacency is unavailable, or the skeleton is too star-like / short for a
    bridging edge to be meaningful, falls back to
    :func:`compute_pose_split_fallback` (2-means bimodality).

    Args:
        points: (N_nodes, 2) coordinate array (NaN for invisible nodes).
        adjacency: Maps node_index -> list of neighbor indices (skeleton
            edges). Pass ``SkeletonAnalyzer.get_adjacency()``. If ``None``, the
            geometry-only fallback is used.
        edge_means: Maps sorted ``(i, j)`` edge tuples -> learned mean edge
            length (reuse ``DatasetStats.edge_means``).
        edge_stds: Maps sorted ``(i, j)`` edge tuples -> learned edge-length std
            (reuse ``DatasetStats.edge_stds``). Values should be floored away
            from zero by the caller (the baseline extractor already does this).
        min_visible: Minimum visible nodes required to attempt a split.

    Returns:
        Dictionary with:

        - ``split_score``: non-negative chimera score (0 = not a chimera; the
          integration layer thresholds this, e.g. on the training distribution).
        - ``split_ratio``: smaller-cluster fraction of visible nodes (0..0.5).
        - ``gap_ratio``: inter-cluster distance / max intra-cluster spread.
    """
    zero = {"split_score": 0.0, "split_ratio": 0.0, "gap_ratio": 0.0}

    vis = _visible_mask(points)
    vis_idx = set(np.where(vis)[0].tolist())
    n_vis = len(vis_idx)

    # Too few visible nodes -> not analyzable.
    if n_vis < min_visible:
        return dict(zero)

    # No adjacency -> geometry-only fallback.
    if not adjacency:
        return compute_pose_split_fallback(points, min_visible=min_visible)

    # Build the visible subgraph's edge list with z-scores, deduplicating
    # undirected edges via sorted tuples.
    visible_edges: list[tuple[int, int]] = []
    seen: set[tuple[int, int]] = set()
    best_z = -np.inf
    best_edge: Optional[tuple[int, int]] = None

    for node, neighbors in adjacency.items():
        if node not in vis_idx:
            continue
        for nb in neighbors:
            if nb not in vis_idx:
                continue
            key = tuple(sorted((int(node), int(nb))))
            if key in seen:
                continue
            seen.add(key)
            visible_edges.append(key)

            mean = edge_means.get(key)
            std = edge_stds.get(key)
            if mean is None or std is None:
                continue
            length = float(np.linalg.norm(points[key[1]] - points[key[0]]))
            z = (length - mean) / max(std, _EPS)
            if z > best_z:
                best_z = z
                best_edge = key

    # Decide whether the graph signal is usable. A star skeleton (a hub with
    # many leaves) cannot be split into two balanced clusters by cutting one
    # edge, and very few internal edges also make the bridging test unreliable.
    # In those cases, defer to the geometry-only fallback.
    if (
        best_edge is None
        or len(visible_edges) < 2
        or _is_star_like(visible_edges, vis_idx)
    ):
        return compute_pose_split_fallback(points, min_visible=min_visible)

    nodes_list = sorted(vis_idx)
    components = _components_after_cut(nodes_list, visible_edges, best_edge)

    # We require the cut to produce exactly two components (a clean bridge). If
    # the subgraph was already disconnected (occlusion) the cut yields >2
    # components; if the edge was redundant (a cycle) it yields 1. Either way it
    # is not a clean chimera bridge.
    if len(components) != 2:
        return dict(zero)

    comp_a, comp_b = components
    idx_a = np.array(sorted(comp_a))
    idx_b = np.array(sorted(comp_b))

    n_small = min(len(idx_a), len(idx_b))
    split_ratio = n_small / n_vis

    centroid_a = points[idx_a].mean(axis=0)
    centroid_b = points[idx_b].mean(axis=0)
    gap = float(np.linalg.norm(centroid_a - centroid_b))

    spread_a = _cluster_spread(points, idx_a)
    spread_b = _cluster_spread(points, idx_b)
    max_spread = max(spread_a, spread_b)
    gap_ratio = gap / max(max_spread, _EPS)

    split_score = _combine_score(
        bridge_z=best_z,
        split_ratio=split_ratio,
        gap_ratio=gap_ratio,
        is_fallback=False,
    )

    return {
        "split_score": float(split_score),
        "split_ratio": float(split_ratio),
        "gap_ratio": float(gap_ratio),
    }

compute_pose_split_fallback(points, min_visible=MIN_VISIBLE_NODES)

Skeleton-agnostic 2-means bimodality fallback.

Used for star/short skeletons or when adjacency is unavailable. Splits the visible nodes into two clusters with 2-means and reports a silhouette-like separation score. This intentionally ignores skeleton edges, so it cannot use a bridging-edge z-score; the gate therefore relies entirely on geometry (balanced split + wide gap).

Parameters:

Name Type Description Default
points ndarray

(N_nodes, 2) coordinate array (NaN for invisible nodes).

required
min_visible int

Minimum visible nodes required to attempt a split.

MIN_VISIBLE_NODES

Returns:

Type Description
dict[str, float]

Dictionary with keys split_score, split_ratio, gap_ratio (all floats, split_score >= 0).

Source code in sleap/qc/features/pose_split.py
def compute_pose_split_fallback(
    points: np.ndarray,
    min_visible: int = MIN_VISIBLE_NODES,
) -> dict[str, float]:
    """Skeleton-agnostic 2-means bimodality fallback.

    Used for star/short skeletons or when adjacency is unavailable. Splits the
    visible nodes into two clusters with 2-means and reports a silhouette-like
    separation score. This intentionally ignores skeleton edges, so it cannot
    use a bridging-edge z-score; the gate therefore relies entirely on geometry
    (balanced split + wide gap).

    Args:
        points: (N_nodes, 2) coordinate array (NaN for invisible nodes).
        min_visible: Minimum visible nodes required to attempt a split.

    Returns:
        Dictionary with keys ``split_score``, ``split_ratio``, ``gap_ratio``
        (all floats, ``split_score >= 0``).
    """
    zero = {"split_score": 0.0, "split_ratio": 0.0, "gap_ratio": 0.0}

    vis = _visible_mask(points)
    vis_idx = np.where(vis)[0]
    n_vis = len(vis_idx)
    if n_vis < min_visible:
        return dict(zero)

    coords = points[vis_idx]

    # 2-means via KMeans (deterministic seeding for test stability).
    try:
        from sklearn.cluster import KMeans

        km = KMeans(n_clusters=2, n_init=10, random_state=0)
        labels = km.fit_predict(coords)
    except Exception:
        return dict(zero)

    idx_a = vis_idx[labels == 0]
    idx_b = vis_idx[labels == 1]
    if len(idx_a) == 0 or len(idx_b) == 0:
        return dict(zero)

    n_small = min(len(idx_a), len(idx_b))
    split_ratio = n_small / n_vis

    pts_a = points[idx_a]
    pts_b = points[idx_b]

    # Bimodality via a boundary-gap test rather than a centroid-distance test.
    # A *uniformly* spread pose (e.g. an evenly spaced line) trivially splits in
    # half with a large centroid distance, so centroid distance alone gives
    # false positives. Instead compare the gap *across the cluster boundary*
    # (nearest pair of points from opposite clusters) to the typical *within*
    # cluster spacing. For two genuine blobs the boundary gap dominates; for a
    # uniform spread they are comparable.
    cross = np.linalg.norm(pts_a[:, None, :] - pts_b[None, :, :], axis=2)
    boundary_gap = float(cross.min())

    within = _typical_within_spacing(pts_a, pts_b)
    gap_ratio = boundary_gap / max(within, _EPS)

    # Silhouette-like separation: how much closer points are to their own
    # cluster than to the other. ~1 for well-separated blobs, ~0 for a uniform
    # spread that was split arbitrarily.
    silhouette = _silhouette_like(pts_a, pts_b)

    split_score = _combine_score(
        bridge_z=gap_ratio,  # No edge z available; use gap as the strength term.
        split_ratio=split_ratio,
        gap_ratio=gap_ratio,
        is_fallback=True,
        silhouette=silhouette,
    )

    return {
        "split_score": float(split_score),
        "split_ratio": float(split_ratio),
        "gap_ratio": float(gap_ratio),
    }