Skip to content

chirality

sleap.qc.features.chirality

Chirality (left/right mirror flip) features.

A whole-instance left/right mirror flip is invariant to every distance- and unsigned-angle-based feature (edge lengths, joint angles, pairwise distances, convex hull, ...), so it is invisible to those detectors. Detecting it requires a signed statistic that encodes which side of the body axis each landmark falls on, i.e. the chirality of the pose.

This module learns, per symmetric landmark pair, the canonical (majority) side of the body axis on which the "left" member of the pair sits, then scores a new instance by the fraction of co-visible symmetric pairs whose observed side disagrees with that learned canonical side:

  • a clean pose scores ~0,
  • a whole-instance mirror flip scores ~1 (every pair disagrees),
  • a partial / subset swap scores an intermediate value.

The signed side of a point p relative to the body axis is the sign of the 2D cross product of the axis vector with (p - axis_origin). This is invariant to translation, uniform scaling, and rotation of the whole instance (it only flips under reflection), which is exactly the property needed to isolate mirror flips from ordinary pose variation.

Local (spine-relative) axis. A single straight body axis (e.g. the chord from nose to tail-base) misjudges the side of a pair whenever the animal curls: a curled-but-correctly-labeled instance then trips the detector. To stay robust to body curvature, each symmetric pair is measured against the local tangent of the body midline near that pair. The midline is supplied as an ordered list of non-symmetric node indices (nose -> tail), forming a polyline; for each pair the pair midpoint is projected onto the nearest midline segment and that segment's tangent is used as the local axis in the cross product. With a single-segment (two-node) midline this reduces exactly to the straight-axis sign, so the two-node axis_node_indices form remains a special case.

The midline polyline is resolved per instance in this order:

  1. the visible nodes of midline_node_indices (the ordered midline), if at least two are visible;
  2. otherwise the two axis_node_indices anchors, if both are visible (a degenerate single-segment midline);
  3. otherwise the first principal component of the visible non-symmetric points (PCA fallback), as a single-segment midline through their centroid.

Functions:

Name Description
compute_chirality

Score a single instance for a left/right mirror flip.

fit_chirality

Learn the canonical signed side per symmetric pair from training poses.

infer_symmetry_pairs_by_name

Infer left/right symmetric pairs from node-name suffixes/prefixes.

order_midline_by_pca

Order midline node indices nose -> tail by their mean PCA projection.

compute_chirality(points, symmetry_pairs, midline_node_indices, model, axis_node_indices=None, min_pairs=2)

Score a single instance for a left/right mirror flip.

For each co-visible symmetric pair with a learned canonical side, the signed side of the left member relative to the local midline tangent is compared to the learned canonical side. The returned chirality_wrong_fraction is the fraction of such pairs whose observed side disagrees with the canonical one.

Parameters:

Name Type Description Default
points ndarray

(n_nodes, 2) array of coordinates (NaN for invisible).

required
symmetry_pairs list[tuple[int, int]]

List of (left_idx, right_idx) symmetric node pairs.

required
midline_node_indices Optional[list[int]]

Ordered (nose -> tail) non-symmetric midline node indices for the body midline polyline. If None or too few are visible, the resolution falls back to axis_node_indices then to a PCA axis. Must match what was passed to :func:fit_chirality.

required
model dict

Model dict returned by :func:fit_chirality.

required
axis_node_indices Optional[tuple[int, int]]

Optional (i, j) two-node anchor used as a single-segment midline fallback (see :func:fit_chirality).

None
min_pairs int

Minimum number of scorable co-visible pairs required for a meaningful score. Below this, chirality_wrong_fraction is 0.0.

2

Returns:

Type Description
dict[str, float]

Dictionary with:

  • "chirality_wrong_fraction": float in [0, 1] (0 = consistent with the canonical chirality, 1 = fully flipped).
  • "n_pairs": number of co-visible pairs that were actually scored.
Source code in sleap/qc/features/chirality.py
def compute_chirality(
    points: np.ndarray,
    symmetry_pairs: list[tuple[int, int]],
    midline_node_indices: Optional[list[int]],
    model: dict,
    axis_node_indices: Optional[tuple[int, int]] = None,
    min_pairs: int = 2,
) -> dict[str, float]:
    """Score a single instance for a left/right mirror flip.

    For each co-visible symmetric pair with a learned canonical side, the signed
    side of the *left* member relative to the **local** midline tangent is
    compared to the learned canonical side. The returned
    ``chirality_wrong_fraction`` is the fraction of such pairs whose observed
    side disagrees with the canonical one.

    Args:
        points: ``(n_nodes, 2)`` array of coordinates (NaN for invisible).
        symmetry_pairs: List of ``(left_idx, right_idx)`` symmetric node pairs.
        midline_node_indices: Ordered (nose -> tail) non-symmetric midline node
            indices for the body midline polyline. If ``None`` or too few are
            visible, the resolution falls back to ``axis_node_indices`` then to
            a PCA axis. Must match what was passed to :func:`fit_chirality`.
        model: Model dict returned by :func:`fit_chirality`.
        axis_node_indices: Optional ``(i, j)`` two-node anchor used as a
            single-segment midline fallback (see :func:`fit_chirality`).
        min_pairs: Minimum number of scorable co-visible pairs required for a
            meaningful score. Below this, ``chirality_wrong_fraction`` is 0.0.

    Returns:
        Dictionary with:

        - ``"chirality_wrong_fraction"``: float in ``[0, 1]`` (0 = consistent
          with the canonical chirality, 1 = fully flipped).
        - ``"n_pairs"``: number of co-visible pairs that were actually scored.
    """
    points = np.asarray(points, dtype=float)
    canonical_side: dict[tuple[int, int], float] = model.get("canonical_side", {})

    pairs = [tuple(p) for p in symmetry_pairs]
    midline = list(midline_node_indices) if midline_node_indices else None
    exclude_indices = {idx for pair in pairs for idx in pair}

    polyline = _resolve_midline(points, midline, axis_node_indices, exclude_indices)
    if polyline is None:
        return {"chirality_wrong_fraction": 0.0, "n_pairs": 0}

    n_pairs = 0
    n_wrong = 0
    for left_idx, right_idx in pairs:
        canonical = canonical_side.get((left_idx, right_idx))
        if canonical is None:
            # No learned canonical side for this pair -> cannot judge.
            continue
        if (
            left_idx >= points.shape[0]
            or right_idx >= points.shape[0]
            or np.isnan(points[left_idx]).any()
            or np.isnan(points[right_idx]).any()
        ):
            continue

        side = _signed_side_local(points[left_idx], points[right_idx], polyline)
        if side is None or side == 0.0:
            # On the axis: ambiguous, do not count for or against a flip.
            continue

        n_pairs += 1
        if side != canonical:
            n_wrong += 1

    if n_pairs < min_pairs:
        return {"chirality_wrong_fraction": 0.0, "n_pairs": n_pairs}

    return {
        "chirality_wrong_fraction": float(n_wrong) / float(n_pairs),
        "n_pairs": n_pairs,
    }

fit_chirality(instances, symmetry_pairs, midline_node_indices=None, axis_node_indices=None)

Learn the canonical signed side per symmetric pair from training poses.

For each symmetric pair (left, right) and each training instance where the pair is co-visible and the body midline is resolvable, the signed side of the left member relative to the local midline tangent is computed. The canonical side is the majority sign across instances (sign of the mean of the per-instance signs).

Parameters:

Name Type Description Default
instances list[ndarray]

List of (n_nodes, 2) pose arrays. NaN marks invisible nodes. Should be clean / canonical labels (e.g. user-labeled).

required
symmetry_pairs list[tuple[int, int]]

List of (left_idx, right_idx) symmetric node pairs.

required
midline_node_indices Optional[list[int]]

Ordered (nose -> tail) list of non-symmetric midline node indices defining the body midline polyline. When at least two of them are visible for an instance, the local tangent of the nearest midline segment is used as that pair's axis. If None or too few are visible, the resolution falls back to axis_node_indices then to a PCA axis.

None
axis_node_indices Optional[tuple[int, int]]

Optional (i, j) two-node anchor used as a single-segment midline fallback when midline_node_indices is unavailable for an instance. Passing only this (with midline_node_indices=None) reproduces the original straight-axis behavior exactly.

None

Returns:

Type Description
dict

A model dict with:

  • "canonical_side": dict[tuple[int, int], float] mapping each symmetric pair to its learned canonical side (+1.0 or -1.0). Pairs that were never observed with a resolvable midline are omitted.
  • "pair_support": dict[tuple[int, int], int] mapping each pair to the number of training instances that contributed to its estimate.
  • "symmetry_pairs": the (normalized) list of pairs used.
  • "midline_node_indices": the ordered midline indices supplied.
  • "axis_node_indices": the anchor indices supplied at fit time.
  • "n_instances": number of training instances seen.
Source code in sleap/qc/features/chirality.py
def fit_chirality(
    instances: list[np.ndarray],
    symmetry_pairs: list[tuple[int, int]],
    midline_node_indices: Optional[list[int]] = None,
    axis_node_indices: Optional[tuple[int, int]] = None,
) -> dict:
    """Learn the canonical signed side per symmetric pair from training poses.

    For each symmetric pair ``(left, right)`` and each training instance where
    the pair is co-visible and the body midline is resolvable, the signed side
    of the *left* member relative to the **local** midline tangent is computed.
    The canonical side is the majority sign across instances (sign of the mean
    of the per-instance signs).

    Args:
        instances: List of ``(n_nodes, 2)`` pose arrays. NaN marks invisible
            nodes. Should be clean / canonical labels (e.g. user-labeled).
        symmetry_pairs: List of ``(left_idx, right_idx)`` symmetric node pairs.
        midline_node_indices: Ordered (nose -> tail) list of non-symmetric
            midline node indices defining the body midline polyline. When at
            least two of them are visible for an instance, the local tangent of
            the nearest midline segment is used as that pair's axis. If ``None``
            or too few are visible, the resolution falls back to
            ``axis_node_indices`` then to a PCA axis.
        axis_node_indices: Optional ``(i, j)`` two-node anchor used as a
            single-segment midline fallback when ``midline_node_indices`` is
            unavailable for an instance. Passing only this (with
            ``midline_node_indices=None``) reproduces the original straight-axis
            behavior exactly.

    Returns:
        A model dict with:

        - ``"canonical_side"``: ``dict[tuple[int, int], float]`` mapping each
          symmetric pair to its learned canonical side (``+1.0`` or ``-1.0``).
          Pairs that were never observed with a resolvable midline are omitted.
        - ``"pair_support"``: ``dict[tuple[int, int], int]`` mapping each pair to
          the number of training instances that contributed to its estimate.
        - ``"symmetry_pairs"``: the (normalized) list of pairs used.
        - ``"midline_node_indices"``: the ordered midline indices supplied.
        - ``"axis_node_indices"``: the anchor indices supplied at fit time.
        - ``"n_instances"``: number of training instances seen.
    """
    pairs = [tuple(p) for p in symmetry_pairs]
    midline = list(midline_node_indices) if midline_node_indices else None
    exclude_indices = {idx for pair in pairs for idx in pair}

    # Accumulate signed sides of the left member per pair across instances.
    side_sums: dict[tuple[int, int], float] = {p: 0.0 for p in pairs}
    side_counts: dict[tuple[int, int], int] = {p: 0 for p in pairs}

    for points in instances:
        points = np.asarray(points, dtype=float)
        polyline = _resolve_midline(points, midline, axis_node_indices, exclude_indices)
        if polyline is None:
            continue

        for left_idx, right_idx in pairs:
            # Require BOTH members visible so the side reflects a genuine,
            # co-visible pair rather than a lone node.
            if (
                left_idx >= points.shape[0]
                or right_idx >= points.shape[0]
                or np.isnan(points[left_idx]).any()
                or np.isnan(points[right_idx]).any()
            ):
                continue

            side = _signed_side_local(points[left_idx], points[right_idx], polyline)
            if side is None or side == 0.0:
                # On the axis: ambiguous, contributes no chirality information.
                continue

            side_sums[(left_idx, right_idx)] += side
            side_counts[(left_idx, right_idx)] += 1

    canonical_side: dict[tuple[int, int], float] = {}
    pair_support: dict[tuple[int, int], int] = {}
    for pair in pairs:
        count = side_counts[pair]
        if count == 0:
            continue
        mean_side = side_sums[pair] / count
        # Sign of the mean = majority side. Break exact ties toward +1.
        canonical_side[pair] = 1.0 if mean_side >= 0.0 else -1.0
        pair_support[pair] = count

    return {
        "canonical_side": canonical_side,
        "pair_support": pair_support,
        "symmetry_pairs": pairs,
        "midline_node_indices": midline,
        "axis_node_indices": axis_node_indices,
        "n_instances": len(instances),
    }

infer_symmetry_pairs_by_name(node_names)

Infer left/right symmetric pairs from node-name suffixes/prefixes.

Used when a skeleton has no symmetries defined (e.g. CVAT-style imports), so that mirror-flip detection still works. Pairs nodes whose names share a stem but differ by a left/right token, e.g. Ear_L/Ear_R, Shoulder_left/Shoulder_right, Haunch_left/Haunch_right, L_Eye/R_Eye.

The single-letter _L/_R form is only honored when a matching stem exists on the other side, which avoids spuriously treating e.g. a lone tail ending in no token as symmetric.

Parameters:

Name Type Description Default
node_names list[str]

Ordered list of node names (index = node index).

required

Returns:

Type Description
list[tuple[int, int]]

List of (left_idx, right_idx) index pairs, ordered by left index. Each node appears in at most one pair.

Source code in sleap/qc/features/chirality.py
def infer_symmetry_pairs_by_name(
    node_names: list[str],
) -> list[tuple[int, int]]:
    """Infer left/right symmetric pairs from node-name suffixes/prefixes.

    Used when a skeleton has no symmetries defined (e.g. CVAT-style imports), so
    that mirror-flip detection still works. Pairs nodes whose names share a stem
    but differ by a left/right token, e.g. ``Ear_L``/``Ear_R``,
    ``Shoulder_left``/``Shoulder_right``, ``Haunch_left``/``Haunch_right``,
    ``L_Eye``/``R_Eye``.

    The single-letter ``_L``/``_R`` form is only honored when a matching stem
    exists on the other side, which avoids spuriously treating e.g. a lone
    ``tail`` ending in no token as symmetric.

    Args:
        node_names: Ordered list of node names (index = node index).

    Returns:
        List of ``(left_idx, right_idx)`` index pairs, ordered by left index.
        Each node appears in at most one pair.
    """
    # Map (orientation:stem) -> {"left": idx, "right": idx}.
    groups: dict[str, dict[str, int]] = {}

    for idx, name in enumerate(node_names):
        parsed = _split_lr_token(name)
        if parsed is None:
            continue
        key, side, _is_left = parsed
        bucket = groups.setdefault(key, {})
        # First occurrence wins for a given side (deterministic, stable order).
        bucket.setdefault(side, idx)

    pairs: list[tuple[int, int]] = []
    used: set[int] = set()
    for bucket in groups.values():
        if "left" in bucket and "right" in bucket:
            left_idx = bucket["left"]
            right_idx = bucket["right"]
            if left_idx in used or right_idx in used or left_idx == right_idx:
                continue
            pairs.append((left_idx, right_idx))
            used.add(left_idx)
            used.add(right_idx)

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

order_midline_by_pca(instances, midline_node_indices, min_points=2)

Order midline node indices nose -> tail by their mean PCA projection.

The body midline polyline needs its nodes in anatomical order, but a skeleton's graph topology does not always provide it (a star-topology skeleton, for example, attaches several midline nodes to a single hub with no path between them). This orders the supplied midline nodes by the average of their projections onto the first principal component of the non-symmetric points, which recovers the nose -> tail ordering robustly across topologies.

The PCA axis has an arbitrary sign; the returned order is therefore unique only up to reversal. Reversing the midline does not change the local-tangent line (only its orientation), and the signed-side cross product flips sign consistently for every pair, so the learned canonical sides absorb the choice. The orientation is fixed deterministically (first node gets the smaller mean projection) purely for reproducibility.

Parameters:

Name Type Description Default
instances list[ndarray]

List of (n_nodes, 2) pose arrays used to estimate the axis (e.g. the training instances).

required
midline_node_indices list[int]

Unordered midline (non-symmetric) node indices.

required
min_points int

Minimum non-symmetric points needed in an instance for it to contribute to the projection estimate.

2

Returns:

Type Description
list[int]

The midline node indices ordered by mean PCA projection. If no instance yields a usable axis, the input order is returned unchanged.

Source code in sleap/qc/features/chirality.py
def order_midline_by_pca(
    instances: list[np.ndarray],
    midline_node_indices: list[int],
    min_points: int = 2,
) -> list[int]:
    """Order midline node indices nose -> tail by their mean PCA projection.

    The body midline polyline needs its nodes in anatomical order, but a
    skeleton's graph topology does not always provide it (a star-topology
    skeleton, for example, attaches several midline nodes to a single hub with
    no path between them). This orders the supplied midline nodes by the average
    of their projections onto the first principal component of the non-symmetric
    points, which recovers the nose -> tail ordering robustly across topologies.

    The PCA axis has an arbitrary sign; the returned order is therefore unique
    only up to reversal. Reversing the midline does not change the local-tangent
    *line* (only its orientation), and the signed-side cross product flips sign
    consistently for every pair, so the learned canonical sides absorb the
    choice. The orientation is fixed deterministically (first node gets the
    smaller mean projection) purely for reproducibility.

    Args:
        instances: List of ``(n_nodes, 2)`` pose arrays used to estimate the
            axis (e.g. the training instances).
        midline_node_indices: Unordered midline (non-symmetric) node indices.
        min_points: Minimum non-symmetric points needed in an instance for it to
            contribute to the projection estimate.

    Returns:
        The midline node indices ordered by mean PCA projection. If no instance
        yields a usable axis, the input order is returned unchanged.
    """
    midline = [int(i) for i in midline_node_indices]
    if len(midline) < 2:
        return midline

    exclude_indices: set[int] = set()  # PCA over all non-NaN points of the body
    proj_sums = {i: 0.0 for i in midline}
    proj_counts = {i: 0 for i in midline}

    for points in instances:
        points = np.asarray(points, dtype=float)
        pca = _pca_axis(points, exclude_indices=exclude_indices, min_points=min_points)
        if pca is None:
            continue
        origin, axis_vec = pca
        for i in midline:
            if 0 <= i < points.shape[0] and not np.isnan(points[i]).any():
                proj_sums[i] += float((points[i] - origin) @ axis_vec)
                proj_counts[i] += 1

    # Nodes that were never visible keep a neutral 0.0 projection and sort
    # stably among themselves (Python's sort is stable on ties).
    if all(c == 0 for c in proj_counts.values()):
        return midline

    def _mean_proj(i: int) -> float:
        return proj_sums[i] / proj_counts[i] if proj_counts[i] else 0.0

    return sorted(midline, key=_mean_proj)