What you'll build
In Part 12 you fine-tuned a ResNet to classify whole photos into one label each. Detection asks a harder question: not just what is in the image, but where, and how many times. In this walkthrough you will generate a small labeled dataset of simple shapes, format it the way YOLO expects, train a YOLOv8 model on it using the ultralytics Python library, read the training and validation metrics correctly, run the trained model on new images, and export it to ONNX so it could be served elsewhere. Every step uses code you can run as shown, on a laptop CPU if needed, with no external downloads beyond the pretrained YOLO weights and the ultralytics package itself.
Why generated shapes instead of a photo dataset? Because the point of this article is the workflow: label format, config file, training call, metrics, inference, export. That workflow is identical whether your boxes are drawn around circles and triangles or around cats, forklifts or tumors. Using a tiny synthetic dataset means the whole thing trains in minutes on a CPU, the labels are guaranteed correct because code generated them, and you can inspect every box by eye before you ever touch a real, messier dataset. Near the end of the article there is a dedicated section on moving from this synthetic setup to your own photos, including labeling tools and how much data you actually need.
A 90-second recap of how YOLO sees an image
Part 7 covered this in depth, so here is only the reminder you need to follow the code. A YOLO model takes a whole image in one forward pass and outputs, for a grid of positions across the image, a set of candidate boxes, each with a confidence score and a set of class probabilities. Older YOLO versions compared these candidates against a fixed set of predefined anchor box shapes per grid cell; the versions shipped in the ultralytics package today (the YOLOv8 and newer YOLO11 families) use an anchor-free head, meaning the model directly regresses box coordinates and a learned distribution over possible box extents, without a hand-picked anchor shape library. Many overlapping candidate boxes are then thinned out with non-maximum suppression, which keeps the highest-confidence box in each cluster of overlapping boxes and discards the rest. None of this changes how you prepare data or call the training function, which is why this article can stay focused on the practical steps; if any of the terms above feel shaky, it is worth re-reading Part 7 before continuing.
Setting up your environment
This tutorial uses the ultralytics Python package, which bundles the YOLOv8 and YOLO11 model architectures, training loop, validation metrics and export tools behind a small, consistent API. At the time of writing, pip installs an 8.x release of the package; the training and inference calls shown below (model.train, model.val, model.predict, model.export) have been stable across 8.x releases, but if an exact argument name differs in your installed version, running help(model.train) in a notebook will show the current signature. The package depends on PyTorch, which pip will install automatically if it is not already present. If you have a CUDA-capable GPU, training will be noticeably faster, but everything in this article is sized to also finish in a reasonable time on a CPU.
pip install ultralytics pillow matplotlib
Run that once in your virtual environment. You do not need to install OpenCV or PyTorch separately; ultralytics pulls in the versions it needs. The first time you load a pretrained checkpoint such as yolov8n.pt, the library downloads it automatically from the ultralytics release assets and caches it locally, so that step needs an internet connection once.
Step 1: Create a labeled dataset
The YOLO label format, explained with real numbers
Every object detection framework needs, for each training image, a list of boxes and the class of object inside each box. YOLO's convention stores this as one plain text file per image, with the same filename as the image but a .txt extension, placed in a parallel labels folder. Each line in that file describes one object as five numbers separated by spaces: class_id, x_center, y_center, width, height. The class_id is an integer index into your list of class names, starting at 0. The four position numbers are not pixel coordinates; they are fractions of the image width and height, so every value lies between 0 and 1. This normalization is what lets the same label file apply correctly however the image later gets resized.
Here is a concrete example so the format is never a mystery. Suppose an image is 320 by 320 pixels, and it contains one object whose bounding box runs from pixel (50, 60) in the top left to pixel (150, 180) in the bottom right. The box width in pixels is 150 minus 50, which is 100; the height is 180 minus 60, which is 120. The box center in pixels is the midpoint of each range: x_center_px = (50 + 150) / 2 = 100, y_center_px = (60 + 180) / 2 = 120. To normalize, divide each value by the image dimension it belongs to: x_center = 100 / 320 = 0.3125, y_center = 120 / 320 = 0.375, width = 100 / 320 = 0.3125, height = 120 / 320 = 0.375. If this object is a circle and circle is class 0 in your class list, the label line is exactly: 0 0.312500 0.375000 0.312500 0.375000. An image with three objects simply has three such lines, one per object, in any order.
Writing the dataset generator
The script below creates a folder structure that matches what ultralytics expects: images and labels, each split into train and val subfolders. It draws between one and three shapes per image at random positions and sizes, on a plain background, and computes the exact bounding box of each shape as it draws it, so the labels are correct by construction. Three classes are used: circle, square and triangle. The random seed is fixed so your dataset will match the examples described later in this article.
import random
from pathlib import Path
from PIL import Image, ImageDraw
random.seed(42)
CLASSES = ["circle", "square", "triangle"]
IMG_SIZE = 320
OUT_DIR = Path("shapes_dataset")
def random_color():
return tuple(random.randint(40, 220) for _ in range(3))
def draw_shape(draw, shape, x0, y0, x1, y1, color):
if shape == "circle":
draw.ellipse([x0, y0, x1, y1], fill=color)
elif shape == "square":
draw.rectangle([x0, y0, x1, y1], fill=color)
elif shape == "triangle":
draw.polygon([(x0, y1), ((x0 + x1) // 2, y0), (x1, y1)], fill=color)
def make_image(num_shapes_range=(1, 3)):
img = Image.new("RGB", (IMG_SIZE, IMG_SIZE), (245, 245, 245))
draw = ImageDraw.Draw(img)
labels = []
num_shapes = random.randint(*num_shapes_range)
for _ in range(num_shapes):
class_id = random.randint(0, len(CLASSES) - 1)
shape = CLASSES[class_id]
size = random.randint(30, 90)
x0 = random.randint(0, IMG_SIZE - size)
y0 = random.randint(0, IMG_SIZE - size)
x1, y1 = x0 + size, y0 + size
color = random_color()
draw_shape(draw, shape, x0, y0, x1, y1, color)
x_center = (x0 + x1) / 2 / IMG_SIZE
y_center = (y0 + y1) / 2 / IMG_SIZE
width = (x1 - x0) / IMG_SIZE
height = (y1 - y0) / IMG_SIZE
labels.append((class_id, x_center, y_center, width, height))
return img, labels
def build_split(split_name, n_images):
img_dir = OUT_DIR / "images" / split_name
lbl_dir = OUT_DIR / "labels" / split_name
img_dir.mkdir(parents=True, exist_ok=True)
lbl_dir.mkdir(parents=True, exist_ok=True)
for i in range(n_images):
img, labels = make_image()
img_path = img_dir / f"{split_name}_{i:04d}.jpg"
lbl_path = lbl_dir / f"{split_name}_{i:04d}.txt"
img.save(img_path)
with open(lbl_path, "w") as f:
for class_id, xc, yc, w, h in labels:
f.write(f"{class_id} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}\n")
build_split("train", 240)
build_split("val", 60)
print("Dataset created under", OUT_DIR.resolve())
Running this script creates shapes_dataset/images/train with 240 jpg files, shapes_dataset/images/val with 60 jpg files, and matching .txt label files in shapes_dataset/labels/train and shapes_dataset/labels/val. Two hundred and forty training images is small by object detection standards, but because the task here is simple and the classes are visually distinct, it is enough for the model to learn something clearly measurable within a short training run. Keep the train and val images strictly separate, the same way you would keep a test set separate from training data for a classifier; the val split exists so you can measure performance on images the model never trained on.
Sanity-checking labels before you train
The single most common cause of a detector that trains but never works is a labeling bug: a swapped width and height, a coordinate given in pixels instead of as a fraction, or a class_id that points to the wrong name. Catch this before training by drawing the boxes back onto the image and looking at them. The function below reads one image and its label file, converts the normalized coordinates back to pixels, and draws a rectangle for each line.
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from PIL import Image
CLASSES = ["circle", "square", "triangle"]
def show_labeled_image(image_path, label_path, classes):
img = Image.open(image_path)
w, h = img.size
fig, ax = plt.subplots(1)
ax.imshow(img)
with open(label_path) as f:
for line in f:
class_id, xc, yc, bw, bh = map(float, line.split())
class_id = int(class_id)
box_w = bw * w
box_h = bh * h
x0 = xc * w - box_w / 2
y0 = yc * h - box_h / 2
rect = patches.Rectangle(
(x0, y0), box_w, box_h, linewidth=2, edgecolor="red", facecolor="none"
)
ax.add_patch(rect)
ax.text(x0, max(y0 - 4, 0), classes[class_id], color="red", fontsize=10)
plt.axis("off")
plt.show()
show_labeled_image(
"shapes_dataset/images/train/train_0000.jpg",
"shapes_dataset/labels/train/train_0000.txt",
CLASSES,
)
When you run this, you should see the first training image with a tight red rectangle drawn exactly around each shape, labeled with its class name. If a box is offset, too large, too small, or swapped with the wrong label, fix the generator or the conversion logic before you spend any time training. This five-minute check applies equally to real datasets: whatever tool or converter produced your label files, open a handful of images and look at the boxes before you trust them.
Step 2: Describe the dataset with a YAML file
Ultralytics finds your images and labels, and maps class indices to names, through a small YAML configuration file rather than through command-line flags for every path. The file needs three things: a base path, where to find the training images relative to that path, where to find the validation images, and a dictionary mapping each class index to its name. Ultralytics infers the labels folder automatically by replacing images with labels in the path, which is exactly the folder structure the generator script created above, so no extra work is needed there.
yaml_content = """path: shapes_dataset
train: images/train
val: images/val
names:
0: circle
1: square
2: triangle
"""
with open("shapes_dataset/data.yaml", "w") as f:
f.write(yaml_content)
print("Wrote shapes_dataset/data.yaml")
The order of names matters and must match the class_id values used in your label files exactly: index 0 is whatever your generator called class 0, and so on. This is the second most common source of silent bugs after mislabeled boxes - a data.yaml whose name order does not match the integers in the .txt files will train a model that quietly learns the wrong name for each shape, with no error message at any point, because nothing checks that circle really means circle.
Step 3: Load a pretrained YOLO model
Just as Part 12 started from a ResNet pretrained on ImageNet rather than from random weights, it is standard practice to start detection training from a YOLO checkpoint pretrained on a large general-purpose dataset such as COCO, which contains eighty everyday object categories. The backbone of that pretrained model has already learned to extract useful visual features - edges, textures, shapes, parts - and fine-tuning it on your own classes is far faster and needs far less data than training a detector from scratch. The ultralytics package ships several sizes of pretrained checkpoint; the smallest, yolov8n.pt (n for nano), trades some accuracy for speed and a small memory footprint, which makes it the right choice for a CPU-friendly tutorial and for most first experiments on a new dataset.
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
print(model.model)
The first time this runs, ultralytics downloads yolov8n.pt and caches it. Printing model.model shows the full network architecture: a backbone of convolutional and C2f blocks, a feature pyramid neck that combines features at multiple resolutions, and a detection head. You do not need to understand every line of that printout; what matters is that this object already knows how to detect eighty kinds of object, and training will reshape its head and adjust its weights to detect your three shapes instead.
Step 4: Train the detector
With the dataset described in data.yaml and a pretrained checkpoint loaded, training is a single method call. The arguments below are the ones worth understanding on your first run; ultralytics accepts many more, all with sensible defaults.
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
results = model.train(
data="shapes_dataset/data.yaml",
epochs=30,
imgsz=320,
batch=16,
device="cpu",
patience=10,
project="runs_shapes",
name="yolov8n_shapes",
seed=42,
)
Key training arguments explained
- data: the path to the YAML file that locates your images and labels.
- epochs: how many times the model passes over the entire training set. Thirty is enough to see a clear learning curve on this small, simple dataset; real datasets with more visual variety often need more.
- imgsz: the side length, in pixels, that every image is resized (or letterboxed) to before being fed to the network. 320 matches the size the generator produced and keeps training fast; common choices for real photos are 416, 512 or 640, with larger sizes capturing more detail on small objects at the cost of speed and memory.
- batch: how many images are processed together in one forward and backward pass. 16 is a reasonable default for a nano model on CPU or a modest GPU; lower it if you run out of memory.
- device: "cpu" forces CPU training; on a machine with a CUDA GPU, use "0" (or "0,1" for multiple GPUs) instead, which will train substantially faster.
- patience: stops training early if the validation metric has not improved for this many epochs, which avoids wasting time once the model has stopped learning anything new.
- project and name: where the run's outputs (weights, logs, plots) get saved, here under runs_shapes/yolov8n_shapes.
- seed: fixes the random number generator so the run is reproducible.
What the model is actually optimizing
Each training step combines several loss terms into one number that gets backpropagated. A box loss penalizes how far the predicted box coordinates are from the true box. A classification loss penalizes wrong or unconfident class predictions, similar in spirit to the cross-entropy loss used for the ResNet classifier in Part 12, except it is now computed per candidate box rather than once per image. A third term, often called a distribution focal loss in the anchor-free YOLO architectures, helps the model learn precise box edges by treating each edge coordinate as a small probability distribution over nearby positions rather than a single number, which tends to produce sharper, better-localized boxes than directly regressing one number per edge. You do not need to tune these terms individually for this tutorial; the default weighting ultralytics uses works well across a wide range of datasets, and the one thing worth watching is simply whether the total loss is going down.
Reading the training output
While training runs, ultralytics prints a table, one row per epoch, with columns for each loss term on the training set and, at validation time, detection metrics on the val split: precision, recall, mAP50 and mAP50-95. In a healthy run on a dataset this simple, you should see the loss columns decrease steadily over the first several epochs and then level off, while mAP50 climbs toward a high value, often above 0.9, because distinguishing a red circle from a blue square is a far easier task than the eighty-class COCO problem the base checkpoint was trained on. If instead the losses stay flat and large, the most likely causes are a data.yaml pointing at the wrong folder, label files with pixel coordinates instead of normalized fractions, or a class index mismatch between labels and names - which is exactly why the sanity-check step earlier in this article matters so much.
At the end of training, ultralytics writes the best-performing checkpoint to runs_shapes/yolov8n_shapes/weights/best.pt and the final-epoch checkpoint to last.pt, along with plots of the loss curves and metric curves as PNG files in the same run folder, and a results.csv with every logged number per epoch. Opening results.csv in a spreadsheet or with pandas is a quick way to inspect the full training history without re-running anything.
Step 5: Evaluate the model properly
A model that trains without errors is not the same as a model that works. Call the val method to run a full evaluation pass on the validation split and get back a metrics object.
from ultralytics import YOLO
model = YOLO("runs_shapes/yolov8n_shapes/weights/best.pt")
metrics = model.val(data="shapes_dataset/data.yaml")
print("mAP50:", metrics.box.map50)
print("mAP50-95:", metrics.box.map)
print("Mean precision:", metrics.box.mp)
print("Mean recall:", metrics.box.mr)
The attribute names above (metrics.box.map50, metrics.box.map, metrics.box.mp, metrics.box.mr) match the ultralytics 8.x API; if an attribute is missing in your installed version, printing dir(metrics.box) in a notebook will list what is actually available, since minor API details occasionally shift between releases.
mAP50 and mAP50-95, explained precisely
These two numbers are the standard scoreboard for object detection, and they are worth understanding rather than memorizing. Start with Intersection over Union, or IoU, which Part 7 introduced: given a predicted box and the true box for the same object, IoU is the area where they overlap divided by the area covered by either one of them. An IoU of 1.0 means the boxes match perfectly; an IoU of 0 means they do not overlap at all. To decide whether a prediction counts as correct, detection metrics pick an IoU threshold - commonly 0.5 - and call a prediction a true positive only if its IoU with some true box of the same class is at least that threshold, and a false positive otherwise; true boxes with no matching prediction above the threshold count as false negatives.
From those true positives, false positives and false negatives, you can trace out a precision-recall curve for one class by varying the confidence threshold at which you accept a prediction: at a high threshold you get few but mostly correct predictions (high precision, low recall); at a low threshold you get many predictions, catching more true objects but also more false alarms (higher recall, lower precision). Average precision for a class is, roughly, the area under that precision-recall curve - a single number that rewards a model for being both precise and able to recall most of the true objects, at whatever threshold works best. Mean average precision, or mAP, is simply the average of that number across all your classes.
mAP50 computes this using a single IoU threshold of 0.5, which is a fairly forgiving bar - a box that is somewhat loose around the object still counts as correct. mAP50-95, the metric COCO popularized and the one ultralytics reports by default alongside mAP50, instead computes average precision at ten IoU thresholds from 0.5 to 0.95 in steps of 0.05, and averages across all of them. Because it demands good localization at the strict end of that range, mAP50-95 is always lower than or equal to mAP50 for the same model, and the gap between them tells you something useful: a large gap means the model is finding the right objects but drawing loose, imprecise boxes around them, while a small gap means its boxes are consistently tight. On this synthetic shapes dataset, expect mAP50 to be high because the classes are easy to tell apart, while mAP50-95 will be somewhat lower simply because getting a box within 0.95 IoU of a hand-drawn shape's exact pixel outline is a demanding standard.
Precision, recall, and the confusion matrix
metrics.box.mp and metrics.box.mr give you the mean precision and mean recall at the default confidence and IoU settings ultralytics uses for validation, averaged across classes. These are easier to reason about than mAP for a quick gut check: low recall for the triangle class specifically would tell you the model is missing triangles, perhaps because they are visually similar to something else or underrepresented in training; low precision would tell you the model is predicting triangles where none exist. The validation run also writes a confusion matrix image into the run folder, which breaks performance down class by class and is worth opening whenever a dataset has more than a couple of classes and overall numbers alone do not explain where the errors are coming from.
Step 6: Run inference on new images
With a trained checkpoint, predicting on new images is a single call to predict. The source argument can be a single image path, a folder of images, a video file, or even a webcam index; ultralytics handles all of these through the same interface.
from pathlib import Path
from ultralytics import YOLO
model = YOLO("runs_shapes/yolov8n_shapes/weights/best.pt")
results = model.predict(
source="shapes_dataset/images/val",
conf=0.25,
iou=0.45,
imgsz=320,
save=True,
project="runs_shapes",
name="predict_val",
)
print("Predictions saved to:", Path("runs_shapes/predict_val").resolve())
print("Number of images processed:", len(results))
Confidence and IoU thresholds, and why they matter
conf is the minimum confidence score a candidate box must have to be reported at all; raise it to see only the model's most certain predictions, lower it to see everything it considered, including weak guesses. iou controls the threshold used by non-maximum suppression: when two predicted boxes for the same class overlap by more than this fraction, only the higher-confidence one is kept. A lower iou value merges more aggressively and can remove legitimate separate objects that happen to sit close together; a higher value keeps more overlapping boxes, which helps with crowded scenes but risks duplicate detections of the same object. There is no universally correct setting for either threshold - 0.25 and 0.45 are the common starting defaults in ultralytics - and tuning them is something you do after looking at real predictions on your own validation images, not before.
Visualizing a prediction and reading its output
Each element of the results list returned by predict carries the detected boxes, their classes and confidences for one image, and a convenient plot method that draws them for you.
import matplotlib.pyplot as plt
CLASSES = ["circle", "square", "triangle"]
result = results[0]
annotated = result.plot() # numpy array in BGR order, with boxes drawn
plt.imshow(annotated[..., ::-1]) # convert BGR to RGB for matplotlib
plt.axis("off")
plt.show()
for box in result.boxes:
cls_id = int(box.cls[0])
conf = float(box.conf[0])
x0, y0, x1, y1 = [round(v, 1) for v in box.xyxy[0].tolist()]
print(f"{CLASSES[cls_id]:<8} conf={conf:.2f} box=({x0}, {y0}, {x1}, {y1})")
result.plot() returns an image array with each predicted box drawn in a distinct color, labeled with the class name and confidence score, which is the fastest way to spot problems by eye: boxes drifting off the edge of a shape, a class label that looks wrong, or two overlapping boxes around one object that non-maximum suppression failed to merge. The loop below it prints the same information as plain text - class name, confidence, and the box corners in pixel coordinates (xyxy format, meaning x0, y0, x1, y1, as opposed to the x_center/y_center/width/height format used in the label files) - which is the form you would pass on to any downstream code that needs to crop, count, or act on the detections.
Step 7: Export the model for deployment
A .pt checkpoint is convenient for continuing to work in Python with ultralytics, but many deployment targets - a web backend, a mobile app, an edge device - expect a more portable format. ONNX (Open Neural Network Exchange) is a widely supported one, and ultralytics can produce it with a single call.
from ultralytics import YOLO
model = YOLO("runs_shapes/yolov8n_shapes/weights/best.pt")
exported_path = model.export(format="onnx", imgsz=320)
print("Exported model to:", exported_path)
This writes a .onnx file next to best.pt in the same weights folder. The exported graph expects the same imgsz used during export, so keep that value consistent with how the model was trained unless you deliberately re-export at a different size. ONNX is not the only option - ultralytics also supports exporting to TensorRT, CoreML, TFLite and others - but ONNX is a reasonable default because it is readable by many inference runtimes without locking you into a specific vendor's toolchain. Exporting and actually serving a model behind an API is its own topic, picked up in the project that closes this learning path.
Moving from synthetic shapes to your own real photos
Everything above generalizes directly to a real dataset of your own photos; only the data preparation step changes in practice, since real images do not come with ready-made label files.
Labeling tools
To draw bounding boxes on your own photos and get YOLO-format label files out, you need an annotation tool rather than hand-writing text files. LabelImg is a long-standing, simple desktop tool that can export directly in the YOLO txt format described earlier in this article. CVAT is a more full-featured, browser-based tool suited to larger teams and larger datasets, supporting multiple annotation types beyond boxes. Roboflow offers a hosted annotation and dataset-management workflow, including exporters that produce a data.yaml and folder structure compatible with ultralytics directly, which can save the folder-wrangling step shown above. Whichever tool you choose, always run the sanity-check step from this article - drawing the boxes back onto a sample of images - before training, because a labeling-tool misconfiguration (for example, exporting pixel coordinates instead of normalized ones, or exporting class indices in the wrong order) produces exactly the same silent failure mode as a bug in a hand-written generator script.
How much data do you actually need
There is no fixed number that applies to every project, and any specific figure quoted without context should be treated with suspicion. What matters more than a raw image count is covering the variation your model will actually face at inference time: different lighting, different backgrounds, different object scales and orientations, partial occlusion, and a reasonably balanced number of examples per class so the model does not simply learn to predict whichever class appears most often. Detection generally needs more labeled examples than classification for a comparable level of accuracy, because the model has to learn to localize objects precisely as well as classify them, and because each image can contribute very differently depending on how many objects it contains and how cluttered the scene is. A practical way to proceed, rather than guessing a target count up front, is to label a modest first batch, train, look closely at the validation predictions and the confusion matrix, and add more images specifically of whatever the model is getting wrong - false positives on a particular background, missed detections of a particular class or size - rather than adding images at random.
Freezing layers, the detection equivalent of Part 12's fine-tuning strategy
Part 12 discussed freezing most of a pretrained ResNet's layers and training only the final layers first, then optionally unfreezing more of the network for a second, gentler pass. The same idea is available for YOLO training through the freeze argument, which freezes the first N layers of the model (counted as ultralytics indexes them) so their weights do not update during training, leaving only the later layers, including the detection head, to adapt to your classes.
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
results = model.train(
data="shapes_dataset/data.yaml",
epochs=30,
imgsz=320,
batch=16,
freeze=10, # freeze the first 10 layers of the backbone
device="cpu",
)
Freezing early layers is most useful when your dataset is small and visually close to the kind of images the pretrained checkpoint already saw - everyday photos of common objects, say - since the early convolutional filters that detect edges, textures and simple shapes transfer well and do not need to change. It is less useful, and sometimes actively unhelpful, when your images look very different from typical photos, such as microscope images, thermal images or satellite imagery, where even the earliest layers may benefit from adapting. As with Part 12, the only reliable way to know which strategy wins for your dataset is to try both and compare the validation metrics.
Common mistakes and how to avoid them
- Pixel coordinates instead of normalized ones. YOLO label files need fractions between 0 and 1, not pixel values. A box with x_center greater than 1 is an immediate sign of this bug.
- Class index mismatch between data.yaml and label files. Double-check that index 0 in names really corresponds to whatever class your label files call 0, especially after switching annotation tools or merging datasets from different sources.
- Skipping the visual sanity check. Drawing boxes back onto a handful of images takes a few minutes and catches the two mistakes above, plus swapped width and height, before you waste a training run on bad labels.
- Train and validation images that overlap or are near-duplicates. If the same scene, or a slightly cropped version of it, appears in both splits, validation metrics will look better than the model actually performs on genuinely new images.
- Judging the model from loss alone. Loss going down is necessary but not sufficient; always check mAP50, mAP50-95, precision and recall on the validation split, and look at actual predicted images.
- Confusing mAP50 with mAP50-95. A high mAP50 with a much lower mAP50-95 means the model finds the right objects but draws loose boxes; that is a localization problem, not a classification problem, and calls for a different fix than adding more labeled examples of a missed class.
- Using an image size mismatched to your objects. If objects are small relative to the full image, a larger imgsz (512 or 640) preserves more detail for the detector to use; shrinking everything to a small imgsz can make small objects disappear into a handful of pixels.
- Unbalanced classes with no adjustment. If one class has far more labeled examples than another, the model will tend to be better at that class; check per-class precision and recall, not just the averaged mAP, before concluding the model is ready.
- Forgetting that confidence and IoU thresholds are chosen at inference time, not baked into the model. The same trained checkpoint can look much better or worse depending on the conf and iou values passed to predict; tune them by inspecting real output rather than keeping the defaults blindly.
- Training from scratch when a pretrained checkpoint would do. Unless you have a specific reason not to, start from a pretrained checkpoint such as yolov8n.pt; it almost always reaches a better result faster than random initialization, for the same reason transfer learning helped in Part 12.
Summary and what's next
You built a small, correctly labeled object detection dataset from scratch, understood exactly what the five numbers in a YOLO label line mean and why they are normalized, described the dataset to ultralytics with a short YAML file, fine-tuned a pretrained YOLOv8 model on it, and learned to read the metrics that actually tell you whether a detector works: precision, recall, mAP50 and the stricter mAP50-95, along with what a gap between the two mAP values reveals about box quality versus classification quality. You ran the trained model on new images, inspected its predictions both visually and as raw numbers, and exported it to ONNX. Along the way, the article connected each step back to ideas from earlier in this path: the anchor-free detection head and non-maximum suppression from Part 7, and the transfer-learning and layer-freezing strategy from Part 12, now applied to a detector instead of a classifier. The dataset was synthetic on purpose, so that every box was correct by construction and the full workflow could run quickly on a CPU; the section on moving to real photos covers the practical differences - labeling tools, how to think about dataset size, and when freezing layers helps - that apply once you point this same workflow at your own images.
The next and final article in this path moves from a single trained model to a working system: a plant disease classifier built on the transfer-learning techniques from Part 12, wrapped in an API so it can be queried from outside a notebook, which is the step that turns a trained checkpoint into something other people or programs can actually use.
Comments (0)
No comments yet. Be the first to share your thoughts.