Skip to content

embedding

sleap_nn.inference.embedding

Inference for the embedding model type: crops -> appearance vectors.

This module hosts:

  • :class:EmbeddingInferenceModel — the lightweight LoadedAssets.inference_model holder that :func:sleap_nn.inference.loaders.load_model_assets builds for an embedding model and that :func:sleap_nn.inference.predictor._build_embedding_layer consumes.
  • :func:embed_labels — embed every detection of an in-memory sio.Labels IN PLACE (attach an sio.Embedding vector to each source detection) and return the vectors + per-detection track names. The forward routes through the native-framework :class:~sleap_nn.inference.layers.embedding.EmbeddingLayer, so the crop pipeline (grayscale + optional mask burn-in + per-crop standardize) is IDENTICAL to training and the embeddings are consistent with the validation retrieval metrics. Used by the .slp writer below.
  • :func:embed_labels_for_eval — the same kernel for the post-training retrieval eval in :mod:sleap_nn.train: selects and groups detections by identity exactly as the per-epoch validation metric that picked the checkpoint.
  • :func:predict_embeddings_to_slp — the re-ID entry point: embed every detection in a .slp and persist the vectors via the sleap-io sio.Embedding data model back into a .slp. With a tracker_config (WF2: "embed + track on the fly") every detection — tracked OR untracked — is embedded and :func:~sleap_nn.inference.tracking.apply_tracking assigns sio.Tracks by cosine similarity. Join an embedding back to its source on its host detection (sio.Instance / sio.SegmentationMask).

Reachable from sleap-nn predict (the CLI routes embedding models here); the pose-packaging :func:sleap_nn.inference.run.predict flow rejects embedding models and points back to this function. A fused -m centroid [-m centered_instance] -m <embedding> -i <video> --tracking command first runs the detection stack and then embeds + tracks the sio.Labels it returns, in memory (see sleap_nn.cli._run_embeddings).

Classes:

Name Description
EmbeddingInferenceModel

Holder for a trained embedding model + the knobs its layer needs.

EmbeddingInputError

A problem with the input handed to the embedding route, not a bug in it.

Functions:

Name Description
embed_labels

Embed every detection in labels IN PLACE and return the vectors.

embed_labels_for_eval

Embed the detections retrieval eval scores, grouped by identity as in training.

embedded_carrier

The carrier :func:embed_labels puts its vectors on, for these labels.

predict_embeddings_to_slp

Embed every detection in data_path and persist the vectors into a .slp.

EmbeddingInferenceModel

Holder for a trained embedding model + the knobs its layer needs.

Mirrors the *InferenceModel holders carried on LoadedAssets for the other model types; consumed by _build_embedding_layer to construct an :class:~sleap_nn.inference.layers.embedding.EmbeddingLayer.

Source code in sleap_nn/inference/embedding.py
@attrs.define(eq=False, repr=False)
class EmbeddingInferenceModel:
    """Holder for a trained ``embedding`` model + the knobs its layer needs.

    Mirrors the ``*InferenceModel`` holders carried on ``LoadedAssets`` for the
    other model types; consumed by ``_build_embedding_layer`` to construct an
    :class:`~sleap_nn.inference.layers.embedding.EmbeddingLayer`.
    """

    torch_model: object  # the EmbeddingLightningModule
    embedding_dim: int
    output_stride: int = 1
    max_stride: int = 1
    input_scale: float = 1.0
    crop_size: Optional[int] = None
    ensure_grayscale: bool = True
    ensure_rgb: bool = False

EmbeddingInputError

Bases: ValueError

A problem with the input handed to the embedding route, not a bug in it.

Raised for inputs the user can fix (a video where a .slp of detections is expected, a file with no detections, an output path that would destroy the input's frames, ...). sleap-nn predict surfaces it as a usage error instead of a traceback. Subclasses ValueError so API callers catching that keep working.

Source code in sleap_nn/inference/embedding.py
class EmbeddingInputError(ValueError):
    """A problem with the input handed to the embedding route, not a bug in it.

    Raised for inputs the user can fix (a video where a ``.slp`` of detections is
    expected, a file with no detections, an output path that would destroy the
    input's frames, ...). ``sleap-nn predict`` surfaces it as a usage error
    instead of a traceback. Subclasses ``ValueError`` so API callers catching that
    keep working.
    """

embed_labels(model_dir, labels, *, device='cuda', batch_size=64, include_untracked=False)

Embed every detection in labels IN PLACE and return the vectors.

Builds the SAME :class:~sleap_nn.data.custom_datasets.EmbeddingDataset as training (centering per crop_centering, grayscale-by-default but RGB-capable via ensure_rgb, optional mask burn-in + background_fill), runs the native :class:~sleap_nn.inference.layers.embedding.EmbeddingLayer, and attaches each crop's vector to its object-exact source detection (the exact sio.Instance / sio.SegmentationMask) via the single identity_embedding slot (sleap-io #535). Both pose and mask (owner_type=3) detections persist their embeddings.

:func:predict_embeddings_to_slp calls it, then writes a .slp. For retrieval eval use :func:embed_labels_for_eval, which groups the detections the way the model was trained instead of by name.

Parameters:

Name Type Description Default
model_dir

Trained embedding model directory (best.ckpt + training_config.yaml resolved).

required
labels Labels

sio.Labels to embed; mutated in place (vectors attached to each detection).

required
device str

Torch device.

'cuda'
batch_size int

Crops per forward pass.

64
include_untracked bool

When False (default) only detections with a track or an identity are embedded. When True every detection is embedded regardless of track (WF2 tracking). Either way predictions are embedded, empty masks are not, and when the file holds both carriers the one the model was trained on is embedded.

False

Returns:

Type Description
Tuple[ndarray, ndarray, int, int]

(embeddings, track_names, n_attached, embedding_dim) where embeddings is (N, D) float32, track_names is (N,) object (the detection's sio.Identity name, else its track name; with include_untracked, always its track name, "" when untracked), n_attached is N, and embedding_dim is D.

Source code in sleap_nn/inference/embedding.py
def embed_labels(
    model_dir,
    labels: sio.Labels,
    *,
    device: str = "cuda",
    batch_size: int = 64,
    include_untracked: bool = False,
) -> Tuple[np.ndarray, np.ndarray, int, int]:
    """Embed every detection in ``labels`` IN PLACE and return the vectors.

    Builds the SAME :class:`~sleap_nn.data.custom_datasets.EmbeddingDataset` as
    training (centering per ``crop_centering``, grayscale-by-default but RGB-capable via
    ``ensure_rgb``, optional mask burn-in + ``background_fill``), runs the native
    :class:`~sleap_nn.inference.layers.embedding.EmbeddingLayer`, and attaches each
    crop's vector to its **object-exact** source detection (the exact ``sio.Instance`` /
    ``sio.SegmentationMask``) via the single ``identity_embedding`` slot (sleap-io #535).
    Both pose and mask (``owner_type=3``) detections persist their embeddings.

    :func:`predict_embeddings_to_slp` calls it, then writes a ``.slp``. For retrieval
    eval use :func:`embed_labels_for_eval`, which groups the detections the way the
    model was trained instead of by name.

    Args:
        model_dir: Trained ``embedding`` model directory (``best.ckpt`` +
            ``training_config.yaml`` resolved).
        labels: ``sio.Labels`` to embed; mutated in place (vectors attached to each
            detection).
        device: Torch device.
        batch_size: Crops per forward pass.
        include_untracked: When ``False`` (default) only detections with a track or
            an identity are embedded. When ``True`` every detection is embedded
            regardless of track (WF2 tracking). Either way predictions are embedded,
            empty masks are not, and when the file holds both carriers the one the
            model was trained on is embedded.

    Returns:
        ``(embeddings, track_names, n_attached, embedding_dim)`` where ``embeddings`` is
        ``(N, D) float32``, ``track_names`` is ``(N,)`` object (the detection's
        ``sio.Identity`` name, else its track name; with ``include_untracked``, always
        its track name, ``""`` when untracked), ``n_attached`` is ``N``, and
        ``embedding_dim`` is ``D``.
    """
    embedded = _embed_detections(
        model_dir,
        labels,
        device=device,
        batch_size=batch_size,
        include_untracked=include_untracked,
        group_as_trained=False,
    )
    return (
        embedded.embeddings,
        embedded.track_names,
        len(embedded.embeddings),
        embedded.embedding_dim,
    )

embed_labels_for_eval(model_dir, labels, *, device='cuda', batch_size=64)

Embed the detections retrieval eval scores, grouped by identity as in training.

Selects and groups the detections exactly as the model's per-epoch validation set did (EmbeddingEvaluationCallback, which selected the checkpoint), so a post-training metric is the same metric. Both read data_config.identity.track_names_are_global and the objective's positives.scope from the training config (:func:~sleap_nn.data.custom_datasets.resolve_embedding_grouping), and group on the dataset's global_group_id, not on names. With the default track_names_are_global=False a per-video track name is not an identity: global_id scope scores only the detections carrying a sio.Identity, and tracklet scope scores each (video, track) as its own group, so track_0 in two videos is two groups. It also follows the training config's data_config.user_instances_only (predictions a user label on the same frame supersedes left out), its smallest-mask rule, and the carrier the model was trained on. Like :func:embed_labels, attaches the vectors to labels in place.

Parameters:

Name Type Description Default
model_dir

Trained embedding model directory.

required
labels Labels

Ground-truth sio.Labels of one split.

required
device str

Torch device.

'cuda'
batch_size int

Crops per forward pass.

64

Returns:

Type Description
Tuple[ndarray, ndarray]

(embeddings, group_ids): (N, D) float32 vectors and their (N,) int64 identity groups.

Raises:

Type Description
EmbeddingInputError

If no detection carries what the grouping needs (a sio.Identity, or a track for tracklet scope or under track_names_are_global).

Source code in sleap_nn/inference/embedding.py
def embed_labels_for_eval(
    model_dir,
    labels: sio.Labels,
    *,
    device: str = "cuda",
    batch_size: int = 64,
) -> Tuple[np.ndarray, np.ndarray]:
    """Embed the detections retrieval eval scores, grouped by identity as in training.

    Selects and groups the detections exactly as the model's per-epoch validation
    set did (``EmbeddingEvaluationCallback``, which selected the checkpoint), so a
    post-training metric is the same metric. Both read
    ``data_config.identity.track_names_are_global`` and the objective's
    ``positives.scope`` from the training config
    (:func:`~sleap_nn.data.custom_datasets.resolve_embedding_grouping`), and group on
    the dataset's ``global_group_id``, not on names. With the default
    ``track_names_are_global=False`` a per-video track name is not an identity:
    ``global_id`` scope scores only the detections carrying a ``sio.Identity``, and
    ``tracklet`` scope scores each ``(video, track)`` as its own group, so ``track_0``
    in two videos is two groups. It also follows the training config's
    ``data_config.user_instances_only`` (predictions a user label on the same frame
    supersedes left out), its
    smallest-mask rule, and the carrier the model was trained on. Like
    :func:`embed_labels`, attaches the vectors to ``labels`` in place.

    Args:
        model_dir: Trained ``embedding`` model directory.
        labels: Ground-truth ``sio.Labels`` of one split.
        device: Torch device.
        batch_size: Crops per forward pass.

    Returns:
        ``(embeddings, group_ids)``: ``(N, D) float32`` vectors and their ``(N,)
        int64`` identity groups.

    Raises:
        EmbeddingInputError: If no detection carries what the grouping needs (a
            ``sio.Identity``, or a track for ``tracklet`` scope or under
            ``track_names_are_global``).
    """
    embedded = _embed_detections(
        model_dir,
        labels,
        device=device,
        batch_size=batch_size,
        include_untracked=False,
        group_as_trained=True,
    )
    return embedded.embeddings, embedded.group_ids

embedded_carrier(config, labels, include_untracked=True)

The carrier :func:embed_labels puts its vectors on, for these labels.

The rule the embedding dataset applies (:func:~sleap_nn.data.custom_datasets. resolve_embedding_detection_mode): the carrier the model was trained on whenever the labels hold one of its detections, else the other, counted over the detections :func:embed_labels would embed. Computed from the labels and the model config alone, before anything is embedded.

Parameters:

Name Type Description Default
config

The embedding model's training config.

required
labels Labels

The detections to embed.

required
include_untracked bool

As passed to :func:embed_labels.

True

Returns:

Type Description

(carrier, n_detections): "pose" / "mask", and how many of its detections would be embedded (0: nothing would be).

Source code in sleap_nn/inference/embedding.py
def embedded_carrier(config, labels: sio.Labels, include_untracked: bool = True):
    """The carrier :func:`embed_labels` puts its vectors on, for these labels.

    The rule the embedding dataset applies (:func:`~sleap_nn.data.custom_datasets.
    resolve_embedding_detection_mode`): the carrier the model was trained on whenever
    the labels hold one of its detections, else the other, counted over the
    detections :func:`embed_labels` would embed. Computed from the labels and the
    model config alone, before anything is embedded.

    Args:
        config: The embedding model's training config.
        labels: The detections to embed.
        include_untracked: As passed to :func:`embed_labels`.

    Returns:
        ``(carrier, n_detections)``: ``"pose"`` / ``"mask"``, and how many of its
        detections would be embedded (``0``: nothing would be).
    """
    from sleap_nn.data.custom_datasets import (
        EmbeddingMembership,
        embedding_preferred_carrier,
        resolve_embedding_detection_mode,
    )
    from sleap_nn.inference.tracking import MASK_CARRIER, count_carriers

    rules = _selection_rules(config, labels, group_as_trained=False)
    membership = EmbeddingMembership(
        rules.class_names,
        id_scope=rules.id_scope,
        track_names_are_global=rules.track_names_are_global,
        # As `EmbeddingDataset` builds it.
        user_instances_only=rules.user_instances_only and not include_untracked,
        include_untracked=include_untracked,
        min_mask_area=rules.min_mask_area,
        labels=[labels],
    )
    carrier = resolve_embedding_detection_mode(
        [labels], membership.is_member, preferred=embedding_preferred_carrier(config)
    )
    counts = count_carriers(labels, membership.is_member)
    n = counts.n_mask_with if carrier == MASK_CARRIER else counts.n_pose_with
    return carrier, n

predict_embeddings_to_slp(model_paths, data_path=None, output_path=None, device='cuda', batch_size=64, save_embeddings='slp', tracker_config=None, include_untracked=True, labels=None, restore_source_videos=False, remote_kwargs=None)

Embed every detection in data_path and persist the vectors into a .slp.

The appearance vectors persist via the sleap-io sio.Embedding data model (in the identity_embedding slot of each detection's host sio.Instance / sio.SegmentationMask). The crop pipeline matches the trained model's config exactly (see :func:embed_labels). Any vector the input already carried is cleared first, so the output never mixes this model's vectors with another's.

Parameters:

Name Type Description Default
model_paths

Trained embedding model directory (or a list; the embedding model is selected and any stray non-embedding dirs are ignored — the CLI runs the detection stack separately for the fused path). Exactly one embedding model may be given.

required
data_path Optional[str]

.slp file (local path or URL) with the detections to embed. Optional when labels is passed (it is then used only to derive the default output_path).

None
output_path Optional[str]

Output .slp path. Defaults to <data_path>.tracked.slp when tracking, else <data_path>.embeddings.slp (for a URL, the URL's file name in the current directory). Required when labels is passed without a data_path.

None
device str

Torch device.

'cuda'
batch_size int

Crops per forward pass.

64
save_embeddings str

"slp" persists the appearance vectors in the output .slp (attached to each source detection). "none" does not persist them; with tracker_config set, the vectors are still attached so the tracker can consume them, then stripped before the tracked .slp is written (tracks only).

'slp'
tracker_config Optional['TrackerConfig']

Optional :class:~sleap_nn.inference.tracking.TrackerConfig. When set (WF2), :func:~sleap_nn.inference.tracking.apply_tracking assigns sio.Tracks (by appearance with features="embeddings" / scoring_method="cosine_sim", or blended into a geometric score with appearance_weight); the tracked .slp is written. Emits sio.Track only (no global sio.Identity — a track name is not a global animal identity).

None
include_untracked Optional[bool]

Whether to embed untracked detections too. True (the default; None means the same) embeds EVERY detection, tracked or not, so each one in the output carries a vector. False embeds only the tracked detections (the rest are left without a vector).

True
labels Optional[Labels]

Detections to embed, already in memory. When given, data_path is NOT loaded — the fused detect→embed route hands over the sio.Labels its detection stage returned instead of round-tripping them through a temporary .slp.

None
restore_source_videos bool

Forwarded to sleap-io's restore_original_videos. False (the default, matching :func:sleap_nn.inference.run.save_predictions and predict's --restore_source_videos) keeps references to an input .pkg.slp, whose pixels are already present; True restores references to the pre-embedding source videos, which are often unavailable.

False
remote_kwargs Optional[dict]

Options for loading a remote data_path (headers, stream_mode), forwarded to sio.load_slp.

None

Returns:

Type Description
str

The output .slp path.

Raises:

Type Description
ValueError

If no (or more than one) embedding model is among model_paths, or save_embeddings is invalid.

EmbeddingInputError

(a ValueError) If neither data_path nor labels is given, data_path is not a .slp, there is nothing to persist, no detections were found to embed, or the output would overwrite the file the input's frames are stored in.

Source code in sleap_nn/inference/embedding.py
@torch.inference_mode()
def predict_embeddings_to_slp(
    model_paths,
    data_path: Optional[str] = None,
    output_path: Optional[str] = None,
    device: str = "cuda",
    batch_size: int = 64,
    save_embeddings: str = "slp",
    tracker_config: Optional["TrackerConfig"] = None,  # noqa: F821
    include_untracked: Optional[bool] = True,
    labels: Optional[sio.Labels] = None,
    restore_source_videos: bool = False,
    remote_kwargs: Optional[dict] = None,
) -> str:
    """Embed every detection in ``data_path`` and persist the vectors into a ``.slp``.

    The appearance vectors persist via the sleap-io ``sio.Embedding`` data model
    (in the ``identity_embedding`` slot of each detection's host ``sio.Instance`` /
    ``sio.SegmentationMask``). The crop pipeline matches the trained model's config
    exactly (see :func:`embed_labels`). Any vector the input already carried is
    cleared first, so the output never mixes this model's vectors with another's.

    Args:
        model_paths: Trained ``embedding`` model directory (or a list; the embedding
            model is selected and any stray non-embedding dirs are ignored — the CLI
            runs the detection stack separately for the fused path). Exactly one
            embedding model may be given.
        data_path: ``.slp`` file (local path or URL) with the detections to embed.
            Optional when ``labels`` is passed (it is then used only to derive the
            default ``output_path``).
        output_path: Output ``.slp`` path. Defaults to ``<data_path>.tracked.slp`` when
            tracking, else ``<data_path>.embeddings.slp`` (for a URL, the URL's file
            name in the current directory). Required when ``labels`` is passed
            without a ``data_path``.
        device: Torch device.
        batch_size: Crops per forward pass.
        save_embeddings: ``"slp"`` persists the appearance vectors in the output
            ``.slp`` (attached to each source detection). ``"none"`` does not persist
            them; with ``tracker_config`` set, the vectors are still attached so the
            tracker can consume them, then stripped before the tracked ``.slp`` is
            written (tracks only).
        tracker_config: Optional :class:`~sleap_nn.inference.tracking.TrackerConfig`.
            When set (WF2), :func:`~sleap_nn.inference.tracking.apply_tracking`
            assigns ``sio.Track``s (by appearance with ``features="embeddings"`` /
            ``scoring_method="cosine_sim"``, or blended into a geometric score with
            ``appearance_weight``); the tracked ``.slp`` is written. Emits
            ``sio.Track`` only (no global ``sio.Identity`` — a track name is not a
            global animal identity).
        include_untracked: Whether to embed untracked detections too. ``True`` (the
            default; ``None`` means the same) embeds EVERY detection, tracked or not,
            so each one in the output carries a vector. ``False`` embeds only the
            tracked detections (the rest are left without a vector).
        labels: Detections to embed, already in memory. When given, ``data_path`` is
            NOT loaded — the fused ``detect→embed`` route hands over the ``sio.Labels``
            its detection stage returned instead of round-tripping them through a
            temporary ``.slp``.
        restore_source_videos: Forwarded to sleap-io's ``restore_original_videos``.
            ``False`` (the default, matching
            :func:`sleap_nn.inference.run.save_predictions` and ``predict``'s
            ``--restore_source_videos``) keeps references to an input ``.pkg.slp``,
            whose pixels are already present; ``True`` restores references to the
            pre-embedding source videos, which are often unavailable.
        remote_kwargs: Options for loading a remote ``data_path`` (``headers``,
            ``stream_mode``), forwarded to ``sio.load_slp``.

    Returns:
        The output ``.slp`` path.

    Raises:
        ValueError: If no (or more than one) ``embedding`` model is among
            ``model_paths``, or ``save_embeddings`` is invalid.
        EmbeddingInputError: (a ``ValueError``) If neither ``data_path`` nor
            ``labels`` is given, ``data_path`` is not a ``.slp``, there is nothing to
            persist, no detections were found to embed, or the output would
            overwrite the file the input's frames are stored in.
    """
    from sleap_nn.config.utils import get_model_type_from_cfg, resolve_model_dir
    from sleap_nn.inference.loaders import _load_training_config
    from sleap_nn.inference.run import _refuse_overwriting_frame_source

    if isinstance(model_paths, (str, bytes)):
        model_paths = [model_paths]
    model_dirs = [resolve_model_dir(m) for m in model_paths]

    save_embeddings = (save_embeddings or "none").lower()
    if save_embeddings not in ("none", "slp"):
        raise ValueError(
            f"save_embeddings must be one of 'none'|'slp', got {save_embeddings!r}."
        )

    # Select the embedding model dir (the detection stack, if any, is run by the CLI
    # before this point for the fused path).
    model_types = [
        get_model_type_from_cfg(config=_load_training_config(d)[0]) for d in model_dirs
    ]
    embedding_dirs = [d for d, t in zip(model_dirs, model_types) if t == "embedding"]
    if not embedding_dirs:
        raise ValueError(
            "predict_embeddings_to_slp requires an `embedding` model directory."
        )
    if len(embedding_dirs) > 1:
        raise ValueError(
            f"Got {len(embedding_dirs)} `embedding` model directories "
            f"({', '.join(str(d) for d in embedding_dirs)}); pass exactly one. "
            "Each detection has a single appearance-vector slot."
        )
    model_dir = embedding_dirs[0]

    tracking = tracker_config is not None
    inc_untracked = True if include_untracked is None else bool(include_untracked)
    start_time = datetime.now()

    if not tracking and save_embeddings == "none":
        raise EmbeddingInputError(
            "Nothing to persist: save_embeddings='none' without tracking. Pass "
            "save_embeddings='slp' to write the vectors into a .slp, or a tracker_config "
            "to track by appearance."
        )

    if output_path is None:
        if not data_path:
            raise EmbeddingInputError(
                "output_path is required when the detections are passed as labels "
                "without a data_path (there is no input file to name the output "
                "after)."
            )
        output_path = _default_output_path(data_path, tracking)

    if labels is None:
        if not data_path:
            raise EmbeddingInputError(
                "predict_embeddings_to_slp requires either data_path (a .slp of "
                "detections) or labels (detections already in memory)."
            )
        # A lone embedding model EMBEDS EXISTING detections, so its input must be a
        # .slp. Say so, instead of letting a video path die inside h5py with an
        # opaque "file signature not found" OSError.
        if _source_suffix(data_path) != ".slp":
            raise EmbeddingInputError(
                f"data_path must be a .slp of detections to embed, got {data_path!r}. "
                "A lone embedding (re-ID) model attaches appearance vectors to "
                "detections that already exist; to run on a video, pass a detection "
                "model (centroid / centered_instance) alongside the embedding model "
                "so the detections are produced first."
            )
        labels = sio.load_slp(data_path, **(remote_kwargs or {}))
    source_name = data_path if data_path else "<in-memory labels>"

    # Refuse BEFORE the embedding pass: writing over the file a video reads its
    # frames from (e.g. `-o` naming the input .pkg.slp) destroys those frames.
    try:
        _refuse_overwriting_frame_source(labels, output_path)
    except ValueError as e:
        raise EmbeddingInputError(str(e)) from e
    if tracking:
        # What `apply_tracking` would refuse after the embedding pass, refused
        # before it: the carriers these labels hold, and the one the vectors will
        # land on, are known from the labels and the model config already.
        _check_tracking_plan(
            tracker_config,
            model_dir,
            labels,
            inc_untracked,
            vectors_kept=save_embeddings == "slp",
        )

    # Clear every vector the input already carries (both carriers) so that a
    # detection this pass does not embed -- the carrier `EmbeddingDataset` did not
    # pick, an all-NaN pose, an untracked one under include_untracked=False -- cannot
    # keep a previous model's vector and be re-persisted next to this model's.
    _strip_embeddings(labels)

    _, _, n_attached, embedding_dim = embed_labels(
        model_dir,
        labels,
        device=device,
        batch_size=batch_size,
        include_untracked=inc_untracked,
    )
    if n_attached == 0:
        raise EmbeddingInputError(f"No detections found in {source_name} to embed.")

    if tracking:
        # WF2: track on the freshly-attached vectors, then write the tracked .slp.
        # apply_tracking re-assigns sio.Track from scratch (any prior tracks are
        # overwritten); it emits Tracks only (no global sio.Identity — a track name
        # is not a global animal identity).
        from sleap_nn.inference.tracking import apply_tracking

        logger.info(
            f"Attached {n_attached} embeddings (dim={embedding_dim}); "
            f"{_describe_association(tracker_config)}."
        )
        # The input's lineage as it was BEFORE tracking: tracking works in place
        # and replaces (or removes) its `provenance["tracking"]` record.
        input_provenance = dict(getattr(labels, "provenance", None) or {})
        tracked = apply_tracking(labels, tracker_config)
        # save_embeddings controls whether the vectors persist in the tracked .slp;
        # "none" -> tracks only, so strip the attached vectors (belt-and-braces with
        # the explicit save_embedding_vectors flag below).
        persist_vectors = save_embeddings == "slp"
        if not persist_vectors:
            _strip_embeddings(tracked)
        tracked.provenance = _embedding_provenance(
            tracked,
            model_dir,
            data_path,
            start_time=start_time,
            device=device,
            batch_size=batch_size,
            save_embeddings=save_embeddings,
            n_attached=n_attached,
            embedding_dim=embedding_dim,
            tracker_config=tracker_config,
            input_provenance=input_provenance,
        )
        # #536 flipped the save_slp default to False; be explicit either way. Identity
        # links (sio.Track) always persist regardless of this flag.
        # `restore_original_videos=False` by default, matching every other writer in
        # the repo (`save_predictions` / `--restore_source_videos`): a `.pkg.slp`
        # input already carries its pixels, and sleap-io's own default (True) would
        # point the output at a pre-embedding source video that is often absent --
        # so `lf.image` on the tracked output raised FileNotFoundError.
        sio.save_slp(
            tracked,
            output_path,
            embed=False,
            restore_original_videos=restore_source_videos,
            save_embedding_vectors=persist_vectors,
        )
        logger.info(f"Wrote tracked labels to {output_path}")
        return output_path

    # Non-tracking: the .slp of attached vectors IS the output. The embedding model
    # produces appearance vectors, not identities. Any ``sio.Identity`` already on the
    # input labels passes through untouched; we do NOT fabricate identities from track
    # names (a track/class name is not a global animal identity). Both Instance and
    # SegmentationMask (owner_type=3, sleap-io#527) embeddings persist here.
    labels.provenance = _embedding_provenance(
        labels,
        model_dir,
        data_path,
        start_time=start_time,
        device=device,
        batch_size=batch_size,
        save_embeddings=save_embeddings,
        n_attached=n_attached,
        embedding_dim=embedding_dim,
    )
    # This path exists to persist the vectors, so opt in explicitly (#536 default flip).
    # See the tracked save above for why `restore_original_videos` defaults to False.
    sio.save_slp(
        labels,
        output_path,
        embed=False,
        restore_original_videos=restore_source_videos,
        save_embedding_vectors=True,
    )
    logger.info(
        f"Attached {n_attached} embeddings (dim={embedding_dim}) and wrote "
        f"{output_path}"
    )
    return output_path