Ultralytics YOLO27: Join the waitlist.
Get Started
Build with Ultralytics Platform·Build the Dataset·Lesson 4/10
Lessonintermediate

Curate, Dedup, and Split

Find cross-split duplicates and class gaps with two short scripts, split by the unit that matters, and freeze an audited dataset version.

A validation score is only as honest as its split. COCO128, a popular demo dataset, uses the same 128 images for train and val by design, and an accidental version of that in your own data gives you a 'great' model that fails at the next camera. This lesson runs two audits on real data, a duplicate check and a class-per-split audit, then splits by camera and freezes a version that has passed both.

Outcome

You can prove a dataset version has no images shared between splits, no camera or session spanning two splits, and enough instances of every class in validation, then freeze it as a numbered version.

Fast Track
If you already know your way around, here's the short version.
  1. Hash images per split: any hash in two splits is a leak.

  2. Audit the NDJSON export: every class needs training examples and enough val instances to measure.

  3. Run Clustering colored by split to catch near-duplicates that hashes miss.

  4. Assign splits by camera, site, or session; never randomly redistribute video frames.

  5. Create a numbered version with a description, and train only on versions that passed.

Hands-on

Worked Example: Find Images Shared Between Splits#

Platform keeps one copy of an image per split and asks how to handle collisions on upload, but nothing stops the same image or scene landing in both train and val. Check before you upload, and again after merging datasets:

import hashlib
from pathlib import Path

from ultralytics.data.utils import check_det_dataset

data = check_det_dataset("coco128.yaml")  # any local YOLO dataset YAML before you upload it

def hashes(split_dir):
    return {hashlib.md5(p.read_bytes()).hexdigest(): p.name for p in Path(split_dir).rglob("*.jpg")}

train, val = hashes(data["train"]), hashes(data["val"])
shared = train.keys() & val.keys()
print(f"train {len(train)} images, val {len(val)} images, identical in both: {len(shared)}")
print("examples:", sorted(val[h] for h in shared)[:3])
train 128 images, val 128 images, identical in both: 128
examples: ['000000000009.jpg', '000000000025.jpg', '000000000030.jpg']

Every COCO128 validation image is also a training image. That's intentional for a smoke-test dataset, and it's exactly why its validation mAP says nothing about generalization. In your data, any non-zero count is a leak: move or delete one copy of each before training.

Catch Near-Duplicates With Clustering#

Hashes only catch identical bytes. Consecutive video frames, burst photos, and re-encoded copies differ by a few pixels. Open the scatter-chart icon in the gallery toolbar and click Analyze Dataset (20 to 200,000 images, uploaded datasets only):

Ultralytics Platform medical-pills gallery with the Clustering panel open, plotting every image as a point in a 2D scatter where visually similar images sit close together

  • Color by Splits: a tight island that mixes train and val points is a near-duplicate leak.
  • Color by Clusters or Classes: a huge single-class island shows what the dataset over-represents; isolated points are outliers (wrong camera, corrupted frame, mislabeled image).
  • Lasso or click a region to filter the gallery to those images (up to 1,000), then delete, move, or relabel them.

The same embeddings power Find similar images in the image context menu, which adds look-alikes from public and team datasets to train as unlabeled images: the fastest way to fill a gap the audit below finds.

Worked Example: Audit Classes per Split#

Download the NDJSON export from the dataset header or the Versions tab. Each line after the header is one image with its split and labels, so a few lines of Python count every class in every split. Here it runs on the public COCO8 export:

import json
from collections import Counter, defaultdict

counts = defaultdict(Counter)  # split -> class name -> instances
images = Counter()
with open("coco8.ndjson") as f:  # the NDJSON export from the dataset header or Versions tab
    header = json.loads(f.readline())
    names = header["class_names"]
    for line in f:
        record = json.loads(line)
        images[record["split"]] += 1
        for box in (record.get("annotations") or {}).get("boxes", []):
            counts[record["split"]][names[str(box[0])]] += 1

print("images per split:", dict(images))
for name in sorted(set(counts["train"]) | set(counts["val"])):
    train, val = counts["train"][name], counts["val"][name]
    flag = "missing in train" if not train else "too few in val" if val < 10 else ""
    print(f"{name:<14} train {train:>3}  val {val:>3}  {flag}")
images per split: {'train': 4, 'val': 4}
bowl           train   3  val   0  too few in val
broccoli       train   1  val   0  too few in val
dog            train   0  val   1  missing in train
elephant       train   0  val   2  missing in train
giraffe        train   2  val   0  too few in val
horse          train   0  val   2  missing in train
orange         train   4  val   0  too few in val
person         train   0  val  10  missing in train
potted plant   train   1  val   1  too few in val
umbrella       train   0  val   1  missing in train
vase           train   1  val   0  too few in val
zebra          train   1  val   0  too few in val

Reading it: the header line holds class_names; each detection box is [class, x, y, w, h]. Missing in train means the model can't learn the class at all, and its val AP will be zero. Too few in val means its AP will swing wildly between runs: with 7 pallets in val, one missed pallet moves pallet AP by about 14 points. COCO8 is an 8-image smoke test, so every class fails; your production version should print no flags. Fix gaps by collecting more examples, not by tuning.

Ultralytics Platform Classes tab of the medical-pills dataset with a class distribution histogram and a class table listing annotation and image counts

The Classes tab shows the same counts for the live dataset, with a log-scale switch for imbalanced data. Merge into one folds duplicates such as car, automobile, and vehicle into one class without losing annotations; Delete removes a class and all of its annotations.

Split by the Unit You'll Generalize Across#

Frames from one camera, session, or day are correlated. If they appear in both train and val, the model is graded on scenes it has already seen. Hold out whole units of whatever varies in production: new cameras, so hold out cameras; same cameras on new days, so hold out days.

Platform's split tools differ in exactly this respect:

ToolHow it assignsUse it for
train/, val/, test/ folders in an uploadKeeps your assignmentGroup splits prepared offline
Split filter set before uploadingEvery file in that upload goes to the selected splitUploading one camera per split
Move to split in Table viewMoves the selected imagesFixing specific images
Split bar, Redistribute SplitsRandomly reassigns all imagesIndependent photos only

Ultralytics Platform Redistribute Splits dialog on the medical-pills dataset with a split bar and percentage fields set to 80 train, 20 validation, and 0 test

The Split the Dataset Correctly lesson covers choosing the unit; for very small datasets, k-fold cross-validation squeezes more signal out. To check groups after upload, add a group key to the class audit (for example record["file"].split("_")[0] for files named cam07_000123.jpg, or a camera field in image metadata) and report any group that appears in two splits.

Freeze a Version That Passed#

Training needs a fixed snapshot. In the Versions tab, enter a description ("v5: cam07 held out, +80 pallet images") and click New Version. Each version is an immutable, numbered NDJSON snapshot you can download, compare, or restore; the training dialog's Save Dataset Version does the same at launch and reuses a matching version when nothing changed. Versions aren't available for connected cloud or On Premise datasets.

Pitfalls#

Redistribute Splits on video frames

Symptom: after clicking Auto 80/20, validation mAP jumps and production accuracy doesn't. Fix: Redistribute Splits randomly reassigns every image (and removes exact duplicates on the way), putting neighboring frames in train and val. Use upload folders or Move to split for grouped data.

Merging or deleting a class shifts every class ID

Symptom: an old export or a model's predictions suddenly carry the wrong class names. Fix: class IDs are positional, so merging or deleting a class shifts every higher index down. Create a version before any class change, and retrain rather than reuse old label files.

Changing the split mid-project

Symptom: a new run scores 5 points lower, and nothing else changed. Fix: metrics are only comparable on the same split. When you change the split, note it in the version description and rerun the baseline on the new version before comparing anything.

Try It

Run cross_split_dupes.py on your local dataset and class_audit.py on your Platform NDJSON export, with a group key for your camera or session naming. Fix every leak and gap they report (Move to split, Find similar images, more data), rerun both until they're clean, check Clustering colored by split, and create a described version.

Done When
You've finished the lesson when all of these are true.
  • The duplicate check reports identical in both: 0, and no camera or session group spans two splits.

  • The class audit prints no missing in train flags, and you've decided what to do about each too few in val.

  • A numbered version exists whose description names the split rule and what changed.

Show solution

Worked answer for an 8-camera forklift dataset. The duplicate check finds 41 identical images between train and val, all from a re-uploaded folder; delete the val copies. The audit, extended with group = record['file'].split('_')[0], shows cam02 in both train and val, so select its val images in Table view and Move to split > Train. It also flags pallet: train 63, val 7, too few in val: use Find similar images on two pallet images to add 40 unlabeled look-alikes, label them, and move 15 cam07 pallet images into val so cam07 stays whole. Rerun: 0 duplicates, 0 leaking groups, pallet val 22. Clustering colored by split shows no mixed islands. Save v5: cam07 val, cam08 test, +40 pallets.

What's next

Clean, versioned data in hand. Next you'll train on it, read the run, and fine-tune correctly.