Ultralytics YOLO27:
Computer Vision Foundations·Datasets and Labels·Lesson 7/10
Lessonbeginner

Annotation Quality

Know what a YOLO label contains, lint label files for broken rows, and keep annotators consistent with a guideline and a measured calibration round.

A model trained on inconsistent labels learns the inconsistency. Fifty thousand boxes drawn to one clear rule beat half a million drawn to twenty private interpretations, and relabeling is almost always cheaper than a bigger model. This lesson gives you a label linter, a one-page guideline, and a number for annotator agreement.

Outcome

Lint a dataset's label files, write a one-page guideline for one class, and measure agreement between two annotators.

Fast Track
If you already know your way around, here's the short version.
  1. Know the label row for your task: box, polygon, keypoints, or corners.

  2. Lint every label file: column count, class ids, 0–1 coordinates, duplicate boxes.

  3. One-page guideline with an example image for every rule and a do-not-label list.

  4. Calibration round first: everyone labels the same 50 images; scale up only when agreement passes.

  5. Reviewers reject with a reason; they never silently fix.

Hands-on

What a label actually is#

In the YOLO format, every image has a matching .txt file with one row per object. Coordinates are normalized to 0–1 by the image width and height, so labels survive resizing. The row layout depends on the task:

TaskOne labelNotes
Detectionclass x_center y_center width heightBox center and size
Instance segmentationclass x1 y1 x2 y2 ... xn ynPolygon outline
Poseclass x y w h px1 py1 v1 ... pxn pyn vnBox, then keypoints with optional visibility
OBBclass x1 y1 x2 y2 x3 y3 x4 y4Four corners of the rotated box
ClassificationNo label fileImages sorted into one folder per class
Semantic segmentationA PNG mask per imageEach pixel value is a class index
Depth estimationA depth PNG or NPY per imageEach pixel value is a distance

Class indices start at 0 and map to names in the dataset YAML. An image with no objects needs no label file — that's how background images work. The detection format, semantic, and depth dataset pages have worked examples.

Lint your label files#

Broken rows fail silently: a coordinate above 1, a class id the YAML doesn't define, or the same object boxed twice all become noise the model tries to learn. A short linter catches them before training:

from itertools import combinations
from pathlib import Path

from ultralytics.data.utils import check_det_dataset

def iou(a, b):
    """IoU of two normalized (x_center, y_center, width, height) boxes."""
    ax1, ay1, ax2, ay2 = a[0] - a[2] / 2, a[1] - a[3] / 2, a[0] + a[2] / 2, a[1] + a[3] / 2
    bx1, by1, bx2, by2 = b[0] - b[2] / 2, b[1] - b[3] / 2, b[0] + b[2] / 2, b[1] + b[3] / 2
    inter = max(0, min(ax2, bx2) - max(ax1, bx1)) * max(0, min(ay2, by2) - max(ay1, by1))
    return inter / (a[2] * a[3] + b[2] * b[3] - inter)

def check_labels(text, names):
    """Return a list of problems in one YOLO detection label file."""
    problems, boxes = [], []
    for i, row in enumerate(text.split("\n"), 1):
        values = row.split()
        if not values:
            continue
        if len(values) != 5:
            problems.append(f"row {i}: {len(values)} values, expected 5")
        elif int(values[0]) not in names:
            problems.append(f"row {i}: unknown class {values[0]}")
        elif not all(0 <= float(v) <= 1 for v in values[1:]):
            problems.append(f"row {i}: coordinates outside 0-1")
        else:
            boxes.append((i, int(values[0]), [float(v) for v in values[1:]]))
    for (i, ca, a), (j, cb, b) in combinations(boxes, 2):
        if ca == cb and iou(a, b) > 0.8:
            problems.append(f"row {j}: duplicate of row {i} (IoU {iou(a, b):.2f})")
    return problems

broken = "0 0.50 0.50 0.20 0.30\n0 0.51 0.50 0.20 0.30\n3 0.40 0.40 0.10 0.10\n0 1.20 0.50 0.10 0.10\n0 0.50 0.50 0.10"
print("\n".join(check_labels(broken, {0: "pill"})))

data = check_det_dataset("medical-pills.yaml")
files = sorted(Path(data["path"], "labels").rglob("*.txt"))
found = sum(len(check_labels(f.read_text(), data["names"])) for f in files)
print(f"medical-pills: {found} problems in {len(files)} label files")
row 3: unknown class 3
row 4: coordinates outside 0-1
row 5: 4 values, expected 5
row 2: duplicate of row 1 (IoU 0.90)
medical-pills: 0 problems in 115 label files

The hand-made sample shows each error the linter catches; the real dataset passes cleanly. Run it after every labeling batch and before every training run — it takes seconds.

Why labels drift#

Ultralytics Platform annotation editor in fullscreen: cardboard cartons on warehouse robots outlined with polygons, and the class list open beside them

Look at the class panel in this editor: the 14 carton polygons are saved under Grapes, while a Box class sits further down the list — and plane and Plane exist as two separate classes. A linter can't catch either problem, because every row is valid, but a reviewer checking labels against the guideline would spot both in seconds. Most label errors are like this: valid files, wrong decisions.

Annotators rarely label badly because they're careless. They label inconsistently because the rules are ambiguous and each one makes a reasonable but different guess:

  • Where does the box stop? The shadow, a carried bag, the load on a forklift's forks?
  • Truncation. Is a person half outside the frame still a person? From what visible fraction?
  • Occlusion. Is a car visible only as one wheel labeled or skipped?
  • Class boundaries. Is a pickup a car or a truck? Is a scooter a bicycle or other?
  • Do-not-label cases. Reflections, posters, and screens that show the object.

Each choice is fine as long as everyone makes the same one.

A one-page guideline#

It fits on one page, has an example image for every rule, and puts the edge cases first:

## Class: forklift

Include:
- Forklifts with at least 30% of the body visible, moving or parked, any size or color.

Do not label:
- Pallet jacks (no overhead mast) → class `pallet_jack`.
- Reflections in glass or polished floors, and forklifts on posters or screens.

Box rules:
- Include the mast and the full visible body.
- Exclude the load (pallets, boxes) — those have their own classes.
- For partly occluded forklifts, box the visible part only.

Measure agreement in a calibration round#

Before scaling from one annotator to twenty, everyone labels the same 50 images and you compare:

Agreement is the share of objects both annotators drew with the same class and an IoU of at least 0.5 — the practical version of inter-rater reliability:

def iou(a, b):
    """IoU of two (x1, y1, x2, y2) pixel boxes."""
    w = max(0, min(a[2], b[2]) - max(a[0], b[0]))
    h = max(0, min(a[3], b[3]) - max(a[1], b[1]))
    inter = w * h
    return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter)

def agreement(boxes_a, boxes_b, threshold=0.5):
    """Share of objects both annotators drew with the same class and IoU >= threshold."""
    unmatched, matched = list(boxes_b), 0
    for cls_a, box_a in boxes_a:
        best = max(unmatched, key=lambda b: iou(box_a, b[1]) if b[0] == cls_a else -1, default=None)
        if best and best[0] == cls_a and iou(box_a, best[1]) >= threshold:
            unmatched.remove(best)
            matched += 1
    return matched / max(len(boxes_a), len(boxes_b))

# Two annotators label the same calibration image
alice = [("forklift", (100, 200, 400, 600)), ("person", (450, 220, 520, 480)), ("person", (600, 230, 660, 470))]
bob = [("forklift", (100, 120, 400, 600)), ("person", (452, 218, 522, 478)), ("pallet_jack", (600, 230, 660, 470))]
print(f"agreement: {agreement(alice, bob):.0%}")
agreement: 67%

Two kinds of disagreement hide in that 67%. Bob's forklift box starts 80 pixels higher — he included the mast tip — but still matches at IoU 0.83, so it counts as agreement; tighten the box rule if your product measures height. The third object is a real conflict: Alice says person, Bob says pallet_jack. That needs a clearer rule and an example image, then another round. Keep the 50 calibration images: they become the onboarding test for every new annotator.

Reviewers reject, they don't silently fix

A reviewer who quietly corrects labels removes the feedback loop, and annotators never learn what they got wrong. Reject with a reason and let the annotator fix it. Slower for the first hundred images, much faster after.

What to track#

MetricWhat it tells you
Throughput (objects per hour)Capacity and cost
Reject rateWhether quality is drifting
Agreement on a shared overlap setWhether annotators still apply the same rules

When agreement drops, something changed: a new annotator, a new camera, an ambiguous batch, fatigue. When the model later fails on a specific condition, send those exact images back through labeling — the loop active learning automates, and where most real-world accuracy gains come from. Browse and filter failures in your datasets on Ultralytics Platform, and see Dataset Quality Control and Annotation Best Practices for running this at scale.

Try It

Run the linter on your dataset's label folder. Then write a one-page guideline for your most ambiguous class, with an example image per rule and a do-not-label list. Give it and 10 unlabeled images to a teammate without explaining anything, label the same images yourself, and compute agreement with the script. Record the score and every rule you changed because of a disagreement.

Done When
You've finished the lesson when all of these are true.
  • The linter reports zero problems on your labels, or you have fixed every one it found.

  • You have a one-page guideline with an example for each rule and a do-not-label list.

  • You have an agreement score for two annotators and a list of the rules you changed because of it.

Show solution

A guideline for the medical-pills pill class, written after a calibration round on 10 images:

## Class: pill

Include:
- Every tablet inside a blister cavity, including half-visible ones at the image edge.
- Tablets seen through clear plastic, even when glare covers part of them.

Do not label:
- Empty cavities, torn foil, or tablets outside the pack.
- Reflections of tablets in the foil.

Box rules:
- Box the tablet itself, not the plastic bubble around it.
- If two tablets touch, draw two boxes.

How to read the agreement score: suppose the first round scores 82%. Sort the unmatched objects by cause. On blister packs, the usual two are boxes drawn around the plastic bubble instead of the tablet (fixed by the first box rule, with an example image) and glare-covered tablets one annotator skipped (fixed by the second include rule). Rerun on 10 new images; scale up only when the score clears the 90% gate.

What's next

Next: splitting the labeled data so your metrics stay honest — starting with a leak hiding in this very dataset.