Skip to main content
Blog
Tutorial
Oct 19, 20266 min

How to Remap YOLO Class IDs: Merge, Reorder, or Drop

Change the class index in YOLO label files without breaking your dataset. A safe Python script for detection and segmentation labels, plus the data.yaml and cache steps people forget.

You trained a model, looked at the confusion matrix, and saw that sedan and suv are being mixed up constantly. The fix is obvious: merge them into car. Then you open the labels folder and remember that YOLO does not store class names anywhere in the labels. Every line starts with a bare number, and that number only means something because of the order of names in data.yaml.

So "merge two classes" turns into "rewrite the first number on thousands of lines, then make the YAML agree." It is a small job, but a careless one-liner can corrupt a dataset quietly. This guide shows a safe way to do it.

When you need to change class IDs

Most class ID changes fall into one of four cases:

  • Merging classes. Several fine-grained classes become one, as in the sedan and suv example.
  • Reordering classes. You want IDs to match another dataset, a pretrained model's class order, or a list your team agreed on.
  • Dropping a class. A class turned out to be out of scope, and its boxes should go.
  • Combining datasets. Two exports each start at 0, but 0 means person in one and forklift in the other. Both need to be mapped onto one master list before you mix them.

In every case the work is the same: decide an old-to-new table, rewrite the labels, update data.yaml, and clear anything that cached the old labels.

What a YOLO label line looks like

A detection label has one object per line:

2 0.481250 0.633333 0.162500 0.250000

The first token is the class ID. The other four are the normalized box center and size. A segmentation label also starts with the class ID, followed by a variable number of polygon points:

2 0.41 0.52 0.47 0.50 0.55 0.61 0.49 0.70 0.40 0.66

For a class remap, that difference does not matter. Only the first token is the class ID, and nothing after it should change. That rules out tricks like a global find-and-replace of "2 " with "0 ", which would also hit coordinates such as 0.2 in the middle of a polygon.

Write the mapping down first

Before touching files, write the old and new class lists side by side. Here is a realistic case: merge sedan and suv into car, keep truck, and drop bicycle.

Old ID Old name New ID New name
0 car 0 car
1 sedan 0 car
2 suv 0 car
3 truck 1 truck
4 bicycle dropped -

Notice that truck moved from 3 to 1. New IDs must stay contiguous, so a merge usually shifts every class after it. Forgetting that shift is the most common mistake in hand-written remaps.

Dropping a class has a trade-off worth thinking about. Deleting the bicycle lines does not remove bicycles from the images. Those pixels are now unlabeled, which tells the model "this is background." That is fine when the class is truly out of scope, but if you might want it back later, keep a copy of the original labels.

A Python script that rewrites class IDs

The script below uses only the standard library. It walks a labels folder recursively, changes the first token of each non-empty line according to your map, and keeps the rest of the line byte for byte, including Windows line endings. It works for detection, segmentation, and pose labels because it never parses past the class ID.

Safety features:

  • It is a dry run by default. Nothing is written until you pass --write.
  • It refuses to write if it finds an ID that is in neither --map nor --drop, so a forgotten class cannot slip through.
  • It writes each file to a temporary file first and then swaps it in, so an interrupted run does not leave half-written labels.
  • It skips classes.txt if one sits inside the labels folder.
#!/usr/bin/env python3
"""Rewrite class IDs in YOLO .txt label files (detection, segmentation, pose).

Only the first token of each line is changed; the rest of the line is kept as-is.
Runs as a dry run unless --write is passed.
"""

import argparse
import os
import re
import shutil
import sys
import tempfile
from collections import Counter
from pathlib import Path

LINE_RE = re.compile(r"^(\s*)(\S+)(.*)$", re.DOTALL)


def parse_map(pairs):
    mapping = {}
    for pair in pairs:
        for item in pair.split(","):
            old, new = item.split(":")
            mapping[int(old)] = int(new)
    return mapping


def parse_class_id(token):
    value = float(token)
    if not value.is_integer():
        raise ValueError(f"non-integer class id {token!r}")
    return int(value)


def remap_text(text, mapping, drop, keep_unmapped, seen, unmapped):
    out = []
    changed = False
    for line in text.splitlines(keepends=True):
        match = LINE_RE.match(line)
        if not match or not line.strip():
            out.append(line)
            continue
        indent, token, rest = match.groups()
        old_id = parse_class_id(token)
        seen[old_id] += 1
        if old_id in drop:
            changed = True
            continue
        if old_id in mapping:
            new_token = str(mapping[old_id])
        elif keep_unmapped:
            new_token = token
        else:
            unmapped[old_id] += 1
            new_token = token
        if new_token != token:
            changed = True
        out.append(f"{indent}{new_token}{rest}")
    return "".join(out), changed


def write_atomic(path, text):
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".remap-", suffix=".tmp")
    with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
        f.write(text)
    shutil.copymode(path, tmp)
    os.replace(tmp, path)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("labels_dir", type=Path, help="folder with YOLO .txt labels (searched recursively)")
    parser.add_argument("--map", action="append", required=True, help="old:new pairs, e.g. 3:0,5:0,7:1")
    parser.add_argument("--drop", default="", help="comma-separated class IDs whose lines are deleted")
    parser.add_argument("--keep-unmapped", action="store_true", help="leave IDs missing from --map unchanged")
    parser.add_argument("--write", action="store_true", help="rewrite files (default is a dry run)")
    args = parser.parse_args()

    mapping = parse_map(args.map)
    drop = {int(x) for x in args.drop.split(",") if x.strip()}
    files = sorted(p for p in args.labels_dir.rglob("*.txt") if p.name != "classes.txt")

    seen, unmapped = Counter(), Counter()
    pending = []
    for path in files:
        with open(path, encoding="utf-8-sig", newline="") as f:
            text = f.read()
        try:
            new_text, changed = remap_text(text, mapping, drop, args.keep_unmapped, seen, unmapped)
        except ValueError as e:
            sys.exit(f"{path}: {e}")
        if changed:
            pending.append((path, new_text))

    print(f"Scanned {len(files)} label files; {len(pending)} would change.")
    print("Lines per original class ID:", dict(sorted(seen.items())))
    if unmapped:
        sys.exit(f"IDs not in --map or --drop: {dict(sorted(unmapped.items()))}. "
                 "Add them, or pass --keep-unmapped. Nothing was written.")
    if not args.write:
        print("Dry run only. Re-run with --write to apply.")
        return
    for path, new_text in pending:
        write_atomic(path, new_text)
    print(f"Rewrote {len(pending)} files.")


if __name__ == "__main__":
    main()

Run it safely

Back up the labels first. The script is careful, but a wrong map is still a wrong map, and the backup is your undo button.

cp -r dataset/labels dataset/labels_backup

# 1. Dry run: check the per-class line counts
python remap_yolo_classes.py dataset/labels --map 0:0,1:0,2:0,3:1 --drop 4

# 2. Apply
python remap_yolo_classes.py dataset/labels --map 0:0,1:0,2:0,3:1 --drop 4 --write

Read the dry-run counts before you apply. If you expected about 4,000 suv boxes and the script reports 40, you are pointing at the wrong folder or the wrong split.

To swap two classes, map them both ways in one run: --map 0:1,1:0 --keep-unmapped. A single pass handles that correctly because each line is read once.

Run the remap exactly once. It is not idempotent. In the example above, a second run would read the new truck ID 1 as the old sedan ID and turn every truck into a car. If you are not sure whether a folder has been remapped, restore the backup and start over.

Update data.yaml to match

The labels now use the new numbering, so names in data.yaml has to follow. For the example above:

path: /path/to/dataset
train: images/train
val: images/val
names:
  0: car
  1: truck

Ultralytics accepts names either as a list or as an index-to-name mapping. If your file also has an nc line, update it to the new count (here 2) or delete it. Ultralytics stops with an error when nc and the length of names disagree. If you keep a classes.txt next to the labels, rewrite it in the same order.

Clear the label cache

Ultralytics scans your labels once and saves the result in a .cache file next to each labels folder, for example labels/train.cache and labels/val.cache. Recent releases check file sizes and modification times and rescan when labels change. Older releases only compared file paths and total size, and a remap like 3 to 1 leaves every file the same size. Don't count on the check: delete the cache files after any label edit.

find dataset/labels -maxdepth 1 -name "*.cache" -delete

The next training run rescans the labels and rebuilds the cache.

Common pitfalls

  • IDs outside 0 to nc-1. After a remap, every class ID must be lower than the number of names. Ultralytics treats a label file containing a higher ID as corrupt and skips that image with a warning, so you lose training data without a crash.
  • Gaps in the new numbering. Mapping to 0, 1, 3 leaves 2 with no examples and a name that never appears. Keep new IDs contiguous.
  • Remapping one split only. Train and val must use the same numbering. Point the script at the parent labels folder, not at labels/train.
  • Forgetting other consumers. Inference code, dashboards, and evaluation scripts that hard-code class_id == 3 need the same update.
  • Old checkpoints. A model trained on the old IDs predicts the old IDs. Retrain or fine-tune after a remap; do not just swap the YAML.

If your labels are not in YOLO TXT yet

Renaming and merging classes is easier while the data is still in a named format, because you edit names instead of numbers. The free Label Mapper renames and merges classes in COCO JSON, CVAT XML, Pascal VOC XML, LabelMe JSON, CSV, TSV, and JSONL. It does not accept YOLO TXT input, so use the script above for files that are already in YOLO format.

After mapping, the Format Converter can export YOLO labels. Its YOLO export writes classes.txt and data.yaml and assigns class IDs from 0 in order of first appearance. If you need a specific order, check the generated files and run the remap script once more if needed.

References

FAQ

How do I change the class index in YOLO labels?

Rewrite the first number on each line of every label file using an old-to-new mapping, then update names in data.yaml so the order matches. Use a script that only touches the first token rather than a text find-and-replace, which can also change coordinates.

Does remapping work for YOLO segmentation labels?

Yes. Segmentation lines also start with the class ID, followed by polygon points. A script that changes only the first token and leaves the rest of the line alone works for detection, segmentation, and pose labels.

How do I merge two classes in a YOLO dataset?

Map both old IDs to the same new ID, shift the classes after them so the numbering stays contiguous, remove the merged name from data.yaml, and delete the old .cache files before training again.

Do I need to delete the labels.cache file after editing labels?

It is the safe choice. Recent Ultralytics releases rescan when label files change, but older ones could miss an edit that keeps file sizes the same. Deleting the cache costs one rescan.

What happens if a class ID is higher than nc?

Ultralytics marks that label file as corrupt and skips the image with a warning. Training may still run, but on less data than you expect, so check the scan summary after any remap.

Take the next step with your dataset.

Use local AI, managed cloud labeling, and team review in LabelOp. Export your work or train with your own Vast.ai account.

Start free