# Merging annotations

Merging combines annotations from multiple sources into a single dataset.

## Quick start

```python
import sleap_io as sio

base = sio.load_file("manual_annotations.slp")
predictions = sio.load_file("predictions.slp")

base.merge(predictions)
base.save("merged.slp")
```

## How merging works

Merging proceeds in five steps:

1. **[Match skeletons](#skeleton-matching)** — Find corresponding skeletons by node structure
2. **[Match videos](#video-matching)** — Identify same videos across datasets
3. **[Match tracks](#track-matching)** — Map track identities between datasets
4. **[Merge frames](#frame-strategies)** — Combine frames based on the `frame` strategy
5. **[Match instances](#instance-matching)** — Pair instances within overlapping frames

### Preset options {#preset-options}

These options are controlled via parameters to [`Labels.merge()`](#sleap_io.model.labels.Labels.merge):

| Parameter | Controls | Options |
|-----------|----------|---------|
| `skeleton` | How skeletons are matched | [`"structure"`](#skeleton-matching) (default), [`"subset"`](#skeleton-matching), [`"overlap"`](#skeleton-matching), [`"exact"`](#skeleton-matching) |
| `video` | How videos are matched | [`"auto"`](#how-auto-matching-works) (default), [`"path"`](#other-video-matching-methods), [`"basename"`](#other-video-matching-methods), [`"content"`](#other-video-matching-methods), [`"shape"`](#other-video-matching-methods), [`"image_dedup"`](#other-video-matching-methods) |
| `track` | How tracks are matched | [`"identity"`](#track-matching) (default), [`"name"`](#track-matching) (opt-in) |
| `frame` | How overlapping frames are combined | [`"auto"`](#auto-default) (default), [`"replace_predictions"`](#replace_predictions), [`"keep_original"`](#other-frame-strategies), [`"keep_new"`](#other-frame-strategies), [`"keep_both"`](#other-frame-strategies), [`"update_tracks"`](#other-frame-strategies) |
| `instance` | How instances are paired within frames | [`"spatial"`](#instance-matching) (default), [`"identity"`](#instance-matching), [`"iou"`](#instance-matching) |

```python
base.merge(predictions)  # All defaults
base.merge(predictions, video="auto", frame="auto")  # Explicit defaults
```

---

## Matching without merging {#matching-without-merging}

Sometimes you need to inspect matching results without actually merging datasets. This is useful for:

- **Evaluation workflows**: Aligning predictions with ground truth to compute metrics
- **Debugging**: Understanding why videos/skeletons aren't matching as expected
- **Validation**: Checking matches before committing to a merge

Use [`Labels.match()`](#sleap_io.model.labels.Labels.match) to build correspondence maps without modifying either dataset:

```python
import sleap_io as sio

gt_labels = sio.load_slp("ground_truth.slp")
pred_labels = sio.load_slp("predictions.slp")

# Match predictions to ground truth (doesn't modify either)
result = gt_labels.match(pred_labels)

# Inspect results
print(result.summary())
# Videos: 2/2 matched
# Skeletons: 1/1 matched
# Tracks: 0/0 matched

# Check if all videos matched
if not result.all_videos_matched:
    print("Unmatched videos:")
    for video in result.unmatched_videos:
        print(f"  - {video.filename}")

# Iterate through matched videos
for pred_video, gt_video in result.video_map.items():
    if gt_video is not None:
        print(f"{pred_video.filename} -> {gt_video.filename}")
```

### MatchResult properties

The [`MatchResult`](#sleap_io.model.matching.MatchResult) object contains:

| Property | Type | Description |
|----------|------|-------------|
| `video_map` | `dict[Video, Video \| None]` | Maps other's videos to self's videos |
| `skeleton_map` | `dict[Skeleton, Skeleton \| None]` | Maps other's skeletons to self's skeletons |
| `track_map` | `dict[Track, Track \| None]` | Maps other's tracks to self's tracks |
| `unmatched_videos` | `list[Video]` | Videos from other with no match |
| `unmatched_skeletons` | `list[Skeleton]` | Skeletons from other with no match |
| `unmatched_tracks` | `list[Track]` | Tracks from other with no match |
| `all_videos_matched` | `bool` | True if all videos matched |
| `n_videos_matched` | `int` | Count of matched videos |

### Customizing matching

`Labels.match()` accepts the same matching parameters as `merge()`:

```python
# Use specific video matching method
result = gt_labels.match(pred_labels, video="basename")

# Use custom matchers
from sleap_io.model.matching import VideoMatcher, VideoMatchMethod

matcher = VideoMatcher(method=VideoMatchMethod.BASENAME)
result = gt_labels.match(pred_labels, video=matcher)
```

---

## Step 1: Skeleton matching {#skeleton-matching}

Before merging can proceed, skeletons from both datasets must be matched. Each skeleton in the incoming dataset is compared against skeletons in the base dataset to find correspondence.

### Matching methods

| Method | Behavior | Use case |
|--------|----------|----------|
| `"structure"` | Match if same node names, regardless of order | **Default.** Most common case |
| `"subset"` | Match if incoming skeleton nodes are a subset of base | Merging partial annotations |
| `"overlap"` | Match if sufficient overlap between node sets | Flexible matching with threshold |
| `"exact"` | Match only if nodes and edges are identical | Strict validation |

If no match is found, the skeleton is added as new to the base dataset.

### String configuration

```python
# Default: match by structure (same nodes, any order)
base.merge(other, skeleton="structure")

# Allow partial matches (incoming can have fewer nodes)
base.merge(other, skeleton="subset")

# Exact match required (nodes and edges must be identical)
base.merge(other, skeleton="exact")
```

### Object configuration

For advanced control, use [`SkeletonMatcher`](#sleap_io.model.matching.SkeletonMatcher):

```python
from sleap_io.model.matching import SkeletonMatcher

# Overlap matching with custom threshold (70% of nodes must match)
matcher = SkeletonMatcher(method="overlap", threshold=0.7)
base.merge(other, skeleton=matcher)
```

---

## Step 2: Video matching {#video-matching}

Videos must match for frames to merge. If video matching fails, the video is added as new—no frames merge because there's no overlap.

### Design philosophy

The default AUTO algorithm prioritizes **avoiding false positives** (matching wrong videos) over avoiding false negatives (failing to match correct videos):

| Error type | Consequence | Severity |
|------------|-------------|----------|
| **False positive** | Annotations merged to wrong video | **Data corruption** (often unrecoverable) |
| **False negative** | Video added as new | **Safe** (easily fixed, see below) |

**When uncertain, AUTO adds the video as new rather than risk a wrong match.**

### How AUTO matching works

For each incoming video, AUTO runs these checks in order:

| Step | Check | Result |
|------|-------|--------|
| 1 | Shape incompatible (frames, H, W differ) | Reject |
| 2 | Provenance conflict (different `original_video`, verifiable) | Reject |
| 3 | Same physical file (`os.path.samefile`) | **Match** |
| 4 | Exact path string match | **Match** |
| 5 | Unique basename/parent suffix match | **Match** |
| 6 | Pose matching (identical annotations on common frames) | **Match** |
| 7 | Image matching (pixel similarity, if enabled) | **Match** |
| 8 | No match found | Add as new |

**Shape is for rejection only.** Same resolution does NOT imply a match—it just means the videos aren't rejected. This prevents matching unrelated videos that happen to have the same dimensions.

**Shape resolves through the source chain.** A derived video's effective shape is taken from the nearest video in its `source_video` chain that has a known shape (read from in-memory metadata — no file is opened). An **embedded subset** of frames therefore reports its **source's full shape**, so it is shape-compatible with that source and is not rejected on frame count before the definitive same-file check runs. This holds even when the deeper root shape is unknown (e.g. an embedded `.pkg.slp` whose deeper provenance file is not loaded). Genuine siblings that merely share a file but have no source relationship keep their own shapes and are still rejected when their frame counts differ.

**Provenance conflict checking** only rejects when files can be verified on disk. If neither file exists (e.g., embedded videos in `.pkg.slp` files), the check is skipped to allow fall-through to content-based matching.

### Examples

**Cross-platform paths with unique basenames:**
```
Base:  C:/Users/alice/data/fly.mp4
Other: /home/bob/data/fly.mp4
Result: MATCH — "fly.mp4" is unique in both sets
```

**Ambiguous basenames, parent disambiguates:**
```
Base:  /data/exp1/fly.mp4, /data/exp2/fly.mp4
Other: /remote/exp2/fly.mp4
Result: MATCH to exp2/fly.mp4 — parent directory disambiguates
```

**Same basename, different content:**
```
Base:  fly.mp4 (1000 frames)
Other: fly.mp4 (500 frames)
Result: NOT MATCH — shape rejection (different frame counts)
```

**PKG.SLP predictions to external video:**
```
Base: project.slp with /data/fly.mp4
Other: predictions.pkg.slp (embedded, original_video=/data/fly.mp4)
Result: MATCH — provenance chain links to same file
```

**Embedded subset vs restored original (same file, fewer frames):**
```
Base:  labels.pkg.slp (embedded subset: 27 of 80 frames, source_video=/data/fly.mp4)
Other: predictions.slp (restored original /data/fly.mp4: 80 frames)
Result: MATCH — the subset resolves to its source's 80-frame shape, so it is
        shape-compatible and the same-file check links them
```

**Cross-platform embedded videos (pose matching):**
```
Base: valence.pkg.slp (Linux, original_video=/snlkt/.../CHR/fly.mp4)
Other: stress.pkg.slp (Windows, original_video=X:/.../CHR/fly.mp4)
Result: MATCH — poses on common frames are identical
```

### Content-based matching {#content-based-matching}

AUTO mode includes pose-based matching as a default step. When videos have overlapping labeled frames, the matcher compares pose coordinates to identify identical videos even when file paths differ.

#### How pose matching works

1. Find common frame indices between videos
2. For each common frame, check if ANY instance pair has identical poses
3. If enough frames match (default: 3), consider it a match

This is particularly useful for:
- Cross-platform merges (Linux ↔ Windows paths)
- Embedded videos in `.pkg.slp` files that can't be verified on disk
- Videos that have been moved or renamed

#### VideoMatcher parameters

| Parameter | Default | Description |
|-----------|---------|-------------|
| `content_frames` | 3 | Minimum matching frames for confident match |
| `compare_predictions` | `"auto"` | Include predictions: `"auto"`, `True`, or `False` |
| `compare_images` | `False` | Enable image comparison (expensive) |
| `image_similarity_threshold` | 0.05 | Max pixel difference (0-1 scale) |

#### compare_predictions modes

- `"auto"` (default): Include predictions only if the video has NO user instances
- `True`: Always include predictions in comparison
- `False`: Only compare user-labeled instances

#### Image similarity threshold

When `compare_images=True`, frames are compared by mean pixel difference:

- `0.05` (default): ~13/255 pixel difference allowed
- `0.01`: Very strict (~3/255 pixels)
- `0.1`: Lenient (~26/255 pixels)

```python
# Enable image comparison with custom threshold
from sleap_io.model.matching import VideoMatcher

base.merge(other, video=VideoMatcher(
    method="auto",
    compare_images=True,
    image_similarity_threshold=0.1,  # More lenient
))
```

### String configuration

```python
# Default: safe AUTO cascade
base.merge(other, video="auto")

# Exact path match only
base.merge(other, video="path")

# Match by filename only (ignores directory)
base.merge(other, video="basename")
```

### Object configuration

For advanced control, use [`VideoMatcher`](#sleap_io.model.matching.VideoMatcher):

```python
from sleap_io.model.matching import VideoMatcher

# Strict path matching (paths must be identical, no normalization)
matcher = VideoMatcher(method="path", strict=True)
base.merge(other, video=matcher)
```

### Other video matching methods {#other-video-matching-methods}

| Method | Behavior | Use case |
|--------|----------|----------|
| `"auto"` | Safe cascade (default) | Most situations |
| `"path"` | Exact path match only | Strict control |
| `"basename"` | Filename only, ignores directory | Cross-platform (use with caution) |
| `"content"` | Shape + backend type | **Dangerous** — matches any same-resolution video |
| `"shape"` | Match and merge by shape | Image list merging |
| `"image_dedup"` | Deduplicate image lists | Remove duplicate images |

### Handling false negatives

If AUTO doesn't match videos that ARE the same, you have a **false negative**. This is safe—the video was added as new rather than corrupting data. Here's how to detect and fix it:

**Step 1: Detect** — Check video count after merge:
```python
base = sio.load_file("base.slp")
other = sio.load_file("other.slp")

print(f"Before: {len(base.videos)} videos")
result = base.merge(other)
print(f"After: {len(base.videos)} videos")

# If count increased unexpectedly, videos weren't matched
for v in base.videos:
    print(f"  {v.filename}")
```

**Step 2: Verify** — Confirm the videos ARE the same:
```python
# Check shapes match
video_a = base.videos[0]
video_b = base.videos[1]  # The one that should have matched

print(f"Video A: {video_a.shape}")  # e.g., (1000, 480, 640, 3)
print(f"Video B: {video_b.shape}")

# If files exist, check content
if video_a.exists() and video_b.exists():
    # Compare first frame visually or by hash
    frame_a = video_a[0]
    frame_b = video_b[0]
```

**Step 3: Fix** — Use [`replace_filenames`](#sleap_io.model.labels.Labels.replace_filenames) before merging:
```python
# Reload and fix paths before merge
base = sio.load_file("base.slp")
other = sio.load_file("other.slp")

# Option A: Map the specific file
other.replace_filenames(filename_map={
    "/remote/path/fly.mp4": "/local/path/fly.mp4"
})

# Option B: Map by prefix (for multiple videos)
other.replace_filenames(prefix_map={
    "/remote/data": "/local/data"
})

# Now merge
result = base.merge(other)
print(f"Videos after fix: {len(base.videos)}")  # Should match original count
```

**Alternative: Force match with explicit matcher:**
```python
# Only use this if you're CERTAIN the videos are the same
base.merge(other, video="basename")  # Match by filename only
```

---

## Step 3: Track matching {#track-matching}

Tracks represent identities (e.g., individual animals) that persist across frames. During merge, tracks from the incoming dataset are matched to tracks in the base dataset.

!!! warning "Breaking change in 0.8.0"
    The default track matching changed from `"name"` to `"identity"` in v0.8.0.
    Independently loaded files no longer collapse tracks that merely share a name
    (e.g. an arbitrary `"track_0"`). **To restore the pre-0.8.0 behavior, pass
    `track="name"`** (Python) or `--track name` (CLI). This also affects
    [`Labels.match()`](#matching-without-merging), `TrackMatcher`, and `sio merge`.

### Matching methods

| Method | Behavior | Use case |
|--------|----------|----------|
| `"identity"` | Match by track object identity (same `Track` instance). **Default** — correctness-first; never collapses distinct tracks by arbitrary tracker-assigned names | Independently loaded files whose track names (e.g. `"track_0"`) are positional/arbitrary |
| `"name"` | Match tracks with identical names. Opt-in for semantically meaningful names (user-assigned or identity-classification models) | Named individuals; identity-model outputs |

If no match is found, the track is added as new to the base dataset.

!!! note "Asymmetry of errors"
    Name-based **over-merge** (gluing two different animals together because both
    happen to be named `"track_0"`) is harder to detect and undo than
    identity-based **under-merge** (keeping tracks separate that you wanted
    combined), which is visible and recoverable. The default therefore favors
    under-merge; opt in to `"name"` only when track names are meaningful. When you
    do use `"name"`, `merge()` emits a
    [divergence warning](#divergence-warning-for-name-based-merges) if same-named
    tracks appear to be distinct animals.

### String configuration

```python
# Default: match by track object identity (same Track instance).
base.merge(other)
base.merge(other, track="identity")  # explicit default

# Opt-in: match by track name (collapses distinct same-named tracks).
base.merge(other, track="name")
```

### Object configuration

For advanced control, use [`TrackMatcher`](#sleap_io.model.matching.TrackMatcher):

```python
from sleap_io.model.matching import TrackMatcher

# Explicit name matching
matcher = TrackMatcher(method="name")
base.merge(other, track=matcher)
```

### Divergence warning for name-based merges {#divergence-warning-for-name-based-merges}

When matching by name (`track="name"`), `merge()` emits a `UserWarning` if two
same-named tracks collide on a shared `(video, frame)` yet their instances fail
to correspond spatially on **every** overlapping frame — a strong signal that
name-based matching is about to glue distinct tracks together:

```text
UserWarning: Track 'track_0' was merged by name across labels that share video
'...', but instances on that track diverge spatially on all N overlapping
frame(s) ... name-based merging may glue distinct tracks together. Review the
merge or resolve tracks at the instance level.
```

The warning is diagnostic only — the merge still proceeds. It fires at most once
per colliding track, requires a spatial/IoU instance matcher (it is skipped for
`instance="identity"`), and never fires for the default `track="identity"`.

---

## Step 4: Frame strategies {#frame-strategies}

The `frame` parameter controls what happens when both datasets have the same frame (same video and frame index).

### `auto` (default) {#auto-default}

The recommended strategy for human-in-the-loop workflows. Preserves user labels, updates predictions.

| Base instance | Other instance | Result |
|---------------|----------------|--------|
| User label | Prediction | Keep user label |
| User label | User label | Keep base (conflict) |
| Prediction | User label | **Replace** with user label |
| Prediction | Prediction | Replace with newer |

Unmatched instances from `other` are added.

```python
# Typical HITL workflow: merge predictions into labeled project
base.merge(predictions)  # Uses auto by default
```

### `replace_predictions` {#replace_predictions}

Replace all predictions in base with predictions from other. User labels are preserved.

```python
# Re-ran inference, want to update predictions
base.merge(new_predictions, frame="replace_predictions")
```

| Instance type | From base | From other |
|---------------|-----------|------------|
| User label | **Keep** | Ignore |
| Prediction | Remove | **Add** |

### Other frame strategies {#other-frame-strategies}

| Strategy | Behavior | Use case |
|----------|----------|----------|
| `"keep_original"` | Ignore other entirely for overlapping frames | Preserve base annotations |
| `"keep_new"` | Replace base with other for overlapping frames | Overwrite with new annotations |
| `"keep_both"` | Concatenate all instances (creates duplicates) | Manual deduplication later |
| `"update_tracks"` | Copy track assignments only, don't modify poses | Update identity labels |

```python
# Keep only the original annotations
base.merge(other, frame="keep_original")

# Replace with new annotations
base.merge(other, frame="keep_new")

# Keep everything (may create duplicates)
base.merge(other, frame="keep_both")

# Update track assignments without changing poses
base.merge(other, frame="update_tracks")
```

### Negative (background) frames {#negative-frames}

The `is_negative` marker (a frame explicitly labeled as a background/negative training
example) is preserved across all frame strategies. If either the base or the incoming
frame is marked negative, the merged frame stays negative — the marker is never silently
dropped.

The one exception: if the merge produces a real **user pose**, the negative marker is
cleared (a frame with a labeled animal is not a background frame) and the merge records
a `negative_flag_conflict` in `MergeResult.conflicts`. Predicted instances do not clear
the marker, so the predict → merge-back workflow keeps negative frames negative.

---

## Step 5: Instance matching {#instance-matching}

For frame strategies that need to pair instances (`auto`, `update_tracks`), instance matching determines how instances in the base frame correspond to instances in the incoming frame.

### Matching methods

| Method | Behavior | Use case |
|--------|----------|----------|
| `"spatial"` | Match by centroid distance | **Default.** Position-based matching |
| `"identity"` | Match by track identity | Same track assignment |
| `"iou"` | Match by bounding box IoU | Overlap-based matching |

### String configuration

```python
# Default: spatial matching with 5px threshold
base.merge(other, instance="spatial")

# Match by track identity
base.merge(other, instance="identity")

# Match by bounding box overlap
base.merge(other, instance="iou")
```

### Object configuration

For advanced control, use [`InstanceMatcher`](#sleap_io.model.matching.InstanceMatcher):

```python
from sleap_io.model.matching import InstanceMatcher

# Tighter spatial matching (2px threshold)
matcher = InstanceMatcher(method="spatial", threshold=2.0)
base.merge(other, instance=matcher)

# IoU matching with 50% overlap threshold
matcher = InstanceMatcher(method="iou", threshold=0.5)
base.merge(other, instance=matcher)
```

---

## Troubleshooting

### Videos weren't matched (false negative)

See [Handling false negatives](#handling-false-negatives) above.

### Videos matched incorrectly (false positive)

This shouldn't happen with AUTO matching. If it does:

1. Check if videos have identical shapes AND ambiguous paths
2. Use `video="path"` for strict matching
3. Report as a bug—AUTO should be conservative

### Duplicate instances after merge

Use `auto` instead of `keep_both`, or tighten the instance match threshold:

```python
from sleap_io.model.matching import InstanceMatcher
base.merge(other, instance=InstanceMatcher(method="spatial", threshold=2.0))
```

---

## Spatial annotation handling {#spatial-annotations}

In addition to instances, `merge()` handles spatial annotations nested in frames:
centroids, bounding boxes, segmentation masks, ROIs, and label images.

When frames are merged (whether creating new frames or merging into existing ones),
annotations are copied along with the frame. Their **track** and **video** references
are remapped using the same mappings built during skeleton, video, and track matching.
This ensures annotations reference the correct objects in the merged dataset.

Annotation handling follows the same frame merge strategy used for instances:

| Strategy | Annotation behavior |
|----------|---------------------|
| `keep_original` | Keep self's annotations only |
| `keep_new` | Replace with other's annotations |
| `keep_both` | Keep all (deduplicated by identity) |
| `update_tracks` | Spatial matching, then update track assignments on matched annotations |
| `replace_predictions` | Keep user annotations from self, add predicted from other |
| `auto` | Spatial matching + full user-vs-predicted resolution cascade |

For `auto` and `update_tracks`, annotations are matched by centroid distance using the
same threshold as instance matching (default 5 pixels). Each modality is resolved
independently — centroids by `(x, y)`, bounding boxes and ROIs by their centroid, and
masks by the centroid of their bounding box.

For segmentation masks, an explicit provenance link takes precedence over spatial
matching. If a `UserSegmentationMask` records (via `from_predicted`, set by
[`PredictedSegmentationMask.to_user()`](model/segmentation.md#adopting-predictions-human-in-the-loop))
that it was adopted from a `PredictedSegmentationMask` present in the merge, the two are
paired directly — the user correction replaces its exact source prediction regardless of
centroid distance — and spatial matching only resolves the remaining, unlinked
annotations. Other modalities do not yet carry a `from_predicted` link and are matched
spatially only. To list predicted masks that have not been adopted (by link or spatial
overlap), use `LabeledFrame.unused_predicted_masks`.

New frames (no matching frame in the target) always copy all annotations from the
source, regardless of strategy.

### Example: merging with annotations

```pycon
>>> import sleap_io as sio
>>> import numpy as np
>>> skeleton = sio.Skeleton(["head", "thorax", "abdomen"])
>>> video = sio.Video("test.mp4", open_backend=False)
>>> inst1 = sio.Instance.from_numpy(
...     np.array([[10, 20], [30, 40], [50, 60]]),
...     skeleton=skeleton,
... )
>>> lf1 = sio.LabeledFrame(video=video, frame_idx=0, instances=[inst1])
>>> lf1.append(sio.UserBoundingBox(
...     x1=5, y1=15, x2=55, y2=65,
... ))
>>> base = sio.Labels(labeled_frames=[lf1])
>>> inst2 = sio.PredictedInstance.from_numpy(
...     np.array([[10, 20, 0.9], [30, 40, 0.8], [50, 60, 0.7]]),
...     skeleton=skeleton,
...     score=0.9,
... )
>>> lf2 = sio.LabeledFrame(video=video, frame_idx=0, instances=[inst2])
>>> lf2.append(sio.PredictedBoundingBox(
...     x1=6, y1=16, x2=56, y2=66, score=0.9,
... ))
>>> base.merge(sio.Labels(labeled_frames=[lf2]))
>>> print(len(base[0].bboxes))

```

### Per-modality spatial matching

For `auto` and `update_tracks` strategies, annotations are paired by centroid
distance. Each annotation type extracts its centroid differently:

| Modality | Centroid source |
|----------|----------------|
| Centroids | `(x, y)` coordinates directly |
| Bounding boxes | `centroid_xy` property (box center) |
| ROIs | `centroid_xy` property (geometry centroid) |
| Segmentation masks | Centroid of `bbox` (bounding box center) |

The matching threshold is controlled by the `instance` parameter — the same
threshold applies to both instance matching and annotation matching:

```python
from sleap_io.model.matching import InstanceMatcher

# Use a wider threshold (10px) for annotation matching
base.merge(other, instance=InstanceMatcher(method="spatial", threshold=10.0))
```

---

## Merging label images

For segmentation workflows, [`merge_label_images()`][sleap_io.merge_label_images]
merges label images from multiple SLP files into a single file. This is separate
from [`Labels.merge()`](#sleap_io.model.labels.Labels.merge), which merges
keypoint annotations.

```python
import sleap_io as sio

merged = sio.merge_label_images(
    ["batch_0.slp", "batch_1.slp", "batch_2.slp"],
    "all_frames.slp",
)
```

This is designed for parallelized segmentation pipelines where each batch of
frames is processed independently (e.g., with Cellpose or StarDist) and the
results need to be combined.

Key behavior:

- **Zero-decompression copy**: For chunked-format (v2.2) sources, compressed
  chunks are copied directly via `read_direct_chunk` / `write_direct_chunk`
  with no decompression/recompression overhead.
- **Legacy format support**: Blob-format sources (v1.8-v2.1) are transparently
  converted to chunked format during the merge.
- **Video deduplication**: Videos are deduplicated by filename across sources.
  Pass an explicit `video=` argument to override and assign all frames to a
  single video.
- **Track deduplication**: Tracks with the same name across sources are merged
  into a single track.
- **Dimension validation**: All source files must have the same frame dimensions
  `(H, W)`. A `ValueError` is raised if dimensions differ.

!!! tip "See also"
    [`LabelImageWriter`][sleap_io.LabelImageWriter] for streaming writes of
    individual frames — useful for producing the per-batch SLP files that
    `merge_label_images()` can then combine.

---

## Merge history

Every [`merge()`][sleap_io.Labels.merge] appends a record to
`labels.provenance["merge_history"]` (timestamp, source/target filenames, source
stats, strategy, sleap-io version, and result counts). This is audit metadata and
is not consumed by any logic.

Because iterative correct-and-re-merge workflows can run thousands of merges, the
history is **bounded**: only the most recent `max_merge_history` records are kept
(default `DEFAULT_MERGE_HISTORY_LIMIT = 1000`). Pass `max_merge_history=None` to
retain the full history, or a smaller integer to keep fewer records:

```python
labels.merge(other)                       # keep last 1000 records (default)
labels.merge(other, max_merge_history=50) # keep last 50
labels.merge(other, max_merge_history=None)  # keep everything
```

Provenance is stored in the `.slp` file in a dedicated `provenance_json` dataset,
so a large `merge_history` no longer risks exceeding HDF5's metadata limits (see
the [SLP format](formats/slp.md) notes).

## Reference

### Labels.merge

::: sleap_io.model.labels.Labels.merge
    options:
        heading_level: 4

### Labels.match

::: sleap_io.model.labels.Labels.match
    options:
        heading_level: 4

### Labels.add_video

::: sleap_io.model.labels.Labels.add_video
    options:
        heading_level: 4

### Labels.replace_filenames

::: sleap_io.model.labels.Labels.replace_filenames
    options:
        heading_level: 4

### FrameStrategy

::: sleap_io.model.matching.FrameStrategy
    options:
        heading_level: 4

### VideoMatcher

::: sleap_io.model.matching.VideoMatcher
    options:
        heading_level: 4

### SkeletonMatcher

::: sleap_io.model.matching.SkeletonMatcher
    options:
        heading_level: 4

### TrackMatcher

::: sleap_io.model.matching.TrackMatcher
    options:
        heading_level: 4

### InstanceMatcher

::: sleap_io.model.matching.InstanceMatcher
    options:
        heading_level: 4

### MergeResult

::: sleap_io.model.matching.MergeResult
    options:
        heading_level: 4

### MatchResult

::: sleap_io.model.matching.MatchResult
    options:
        heading_level: 4
