How I trained an object detector without drawing a single box

aicomputer-visionyoloobject-detectionhomelab
Mert Cobanov 16 min read
On this page

Every tutorial about training an object detector starts at the same place: here is a dataset, here is the training command, look at the accuracy. That skips the part that actually takes the time. In practice, almost nobody hands you a labeled dataset. You have images at best, and usually not even those, and the work of turning a pile of pictures into something a model can learn from is where the whole afternoon goes.

I wanted to see what that work actually looks like, so I picked a deliberately small problem: a detector that tells a reusable water bottle apart from a disposable plastic one. Two classes, no product to ship, no deadline. Just the pipeline, start to finish, on my own hardware.

This article walks through that pipeline in order: getting images, labeling them, training, and testing. It gets more technical as it goes, and the most useful parts are the three places where something broke. You can go as far into it as you want, the first half stands on its own.

TL; DR

I trained a YOLO26 model to distinguish reusable water bottles from disposable plastic ones. I never drew a single bounding box by hand. The images came from image search, the boxes came from a bigger pretrained model, and I only corrected what that model got wrong, using a small tool I wrote for exactly that job. Training took five minutes on an RTX 3090. Final mAP50 was 0.692.

This article covers:

  • Scraping training images, and why search queries matter more than you expect
  • Pseudo-labeling: using a large model to label data for a small one
  • Why my first attempt threw away half the dataset, and how I caught it
  • Building a purpose-made triage tool instead of using a general labeling app
  • Reading a training curve, and what mAP50 and mAP50-95 actually disagree about
  • A cuDNN version conflict, which is the most common way a working GPU setup breaks

First, which YOLO?

YOLO stands for “You Only Look Once”, a family of object detection models that has been iterated on since 2015. Object detection means the model does two things at once for every image: it finds where objects are (a rectangle, called a bounding box) and says what each one is (a class label).

The current version is YOLO26, released by Ultralytics in January 2026. Two things about it matter here.

The first is that it is end to end. Older detectors predict dozens of overlapping boxes for the same object and then run a cleanup pass called Non-Maximum Suppression (NMS) to keep only the best one. YOLO26 folded that step into the architecture, so it produces clean output in one shot. That mostly simplifies deployment, but it also means fewer knobs to get wrong.

The second is that it ships in five sizes, from n (nano) to x (extra large). This turns out to matter a lot, and not for the reason you would guess.

For a two-class problem the architecture is close to irrelevant. What determines the result is the data. So almost everything below is about data.

Getting images without a camera

I had no photographs, so the images came from DuckDuckGo image search through a small scraper. Sixteen queries, roughly forty results each, 500 images after removing duplicates and anything corrupt or too small.

The interesting decision here is the queries themselves. The obvious search is “water bottle”. Do that and you get e-commerce product photography: white background, centered object, studio lighting, no context. A model trained on those will fail the moment you point a real camera at a real desk, because a webcam frame looks nothing like a catalog shot. This gap between the data you trained on and the data you actually run on is worth naming, it is called domain gap, and it is the single most common reason a scraped dataset disappoints.

So the queries carry context on purpose:

QUERIES = {
    "matara": [
        "person drinking from reusable water bottle",
        "stainless steel water bottle on desk",
        "gym water bottle on bench",
        "hiking water bottle backpack outdoors",
        ...
    ],
    "pet_sise": [
        "person holding plastic water bottle",
        "plastic water bottle on table",
        "disposable plastic bottle on desk",
        ...
    ],
}

matara is the Turkish word for a reusable bottle or canteen, pet_sise is a disposable plastic one. I kept the class names in Turkish because they are shorter and unambiguous to me, and the model does not care what the strings say.

Even with contextual queries the result is messy. One query, “plastic water bottle car cup holder”, returned almost nothing but product photos of car cup holder accessories with no bottle in frame at all. Some results were 3D renders, some were infographics, several were recycling piles with fifty bottles in one image. I did not know that yet, and finding out was the next step.

Labeling without labeling

Here is the part that surprises people: I did not draw any boxes, and neither did any human in this project.

COCO is a widely used object detection dataset from 2014 with about 200,000 human-labeled images across 80 classes. One of those classes is bottle. Every off-the-shelf YOLO model is trained on COCO, which means a pretrained YOLO already finds bottles in images, without any work from me.

So the labeling step becomes: run a pretrained model over my scraped images, keep the boxes it predicts, and write them out in YOLO’s label format. This is called pseudo-labeling, and the chain of custody is worth sitting with for a second:

COCO (2014)        humans hand-labeled 200k+ images

YOLO26             learned from those human labels

my 500 images      the model applied what it learned

557 boxes          what I then corrected

The boxes in my dataset were ultimately drawn by the people who labeled COCO more than a decade ago. Their work is compressed into a set of weights, and that model repeats the job on new images.

The class assignment is a separate trick. A pretrained model knows bottle, but it has never heard of the distinction I care about. So I guessed the class from which folder the image was scraped into: an image from raw/matara/ gets class 0, one from raw/pet_sise/ gets class 1. Since the search query determined the folder, that guess is right most of the time. Wrong guesses are for me to fix later, and fixing a wrong label is much faster than drawing a box from nothing.

The first bug: I threw away half my data

My first pass produced 254 usable images out of 500. Nearly half the dataset had zero detected boxes, and I was about to treat those as unusable.

Before deleting them I built a contact sheet of the rejects and looked at it. That took two minutes and it was the highest-value two minutes in the project. The rejected pile was full of obvious bottles: a steel bottle held against a pink background, a kid drinking from a teal bottle, a crushed plastic bottle on a wooden table, a thermos next to a laptop. The pipeline was wrong, not the data.

To find out why, I re-ran the model on sixty of the rejected images with the class filter removed and the confidence threshold lowered, and counted what classes came back:

person          48
bottle          18
cup             18
cell phone      10
toothbrush       9
bench            8
vase             6

Two separate mistakes, visible in one table.

Mistake one: my confidence threshold was too high. Eighteen of those sixty images did contain a detection labeled bottle. The model had found them, my conf=0.25 cutoff had discarded them.

Mistake two: COCO’s idea of a bottle is narrower than mine. Eighteen came back as cup and six as vase. COCO’s bottle class is dominated by plastic and glass drink bottles. A stainless steel insulated tumbler does not look like those, it looks like a cup or a vase, so that is what the model called it. This is not a model failure. It is the definition it was taught.

The fix for the second was to accept three COCO classes instead of one, since I only need the box position to be right and the class comes from the folder anyway:

WANT_CLASSES = [39, 41, 75]   # bottle, cup, vase

But the more important fix was the one I had not thought about at all.

Use the big model to label, the small model to run

I had used yolo26n, the nano model, for pre-labeling. That was thoughtless. Pre-labeling is an offline job. It runs once, on my machine, with no user waiting. Speed does not matter at all, so choosing the smallest and weakest model bought me nothing and cost me accuracy.

I measured the difference on the same sixty rejected images:

ModelConfidenceImages with a box found
yolo26n0.1523 / 60
yolo26x0.1544 / 60
yolo26x0.2544 / 60

The extra large model nearly doubles it. The confidence threshold stops mattering once the model is good enough, which is itself a useful signal.

This shape shows up throughout machine learning: a large, slow, expensive model produces labels, and a small, fast, cheap model is trained on them. The expensive model runs once. The cheap one runs forever. In this project yolo26x labeled the data and yolo26s got trained on the result.

After both fixes, the same 500 images produced 412 usable ones instead of 254.

One more decision worth mentioning: I kept 25 images where the model found nothing at all. These are negative examples, frames that contain no bottle, and the car cup holder product photos are perfect ones because they look bottle-adjacent without being bottles. A dataset where every single image contains the target teaches the model that there is always something to find, and that produces phantom detections on empty scenes.

A tool for one job

Now the part that needed a human: 557 boxes, each with a class guess that was right maybe 85 percent of the time, plus roughly one in six boxes drawn on something that was not a bottle at all.

The default move here is a general labeling application, something like Roboflow or Label Studio. They are good tools. They are also built for the general case: show the full image, let the user find the object, click the box, pick a class from a menu. Fifteen to twenty seconds per box. For 557 boxes that is close to three hours.

But my job was not the general case. The boxes were already drawn and mostly correct. The only real questions were “which class is this” and “is this box garbage”. That is three possible answers, and three answers means three keys.

So I wrote a 509 line single-file tool. Python’s built in http.server serves the page, Pillow crops each box out of its image on request, and the interface is an HTML string in the same file. No framework, no extra dependency.

The screen shows one box at a time, cropped and enlarged with some surrounding context, plus the full scene in a smaller panel beside it for when the crop alone is ambiguous. Then:

1  matara        2  pet_sise       x  delete this box
z  undo          s  skip

Every keystroke writes to a JSON file immediately, so the work is resumable. Two seconds per decision instead of twenty. The whole pass took about twenty five minutes.

The tradeoff is that you cannot adjust box geometry, only delete a bad box. That is the right trade when roughly three quarters of the boxes are already good, because deleting a bad box is cheap and redrawing one is not.

I added a second screen afterward, a gallery view that groups every box by its assigned class into three grids. This does something the one-at-a-time view cannot: seeing 208 bottles side by side makes the three or four that do not belong jump out immediately. Sequential review catches individual errors, grid review catches inconsistency.

The final tally from 556 decisions: 107 boxes deleted, 66 class corrections, leaving 450 boxes across 412 images. 208 matara, 242 pet_sise, plus 53 images with no boxes at all serving as negatives.

Training

Split into 329 training images and 83 validation images, and then the actual training is one call:

from ultralytics import YOLO

model = YOLO("yolo26s.pt")

model.train(
    data="dataset_final/dataset.yaml",
    epochs=100,
    imgsz=640,
    batch=16,
    device=0,
    cache=True,
    patience=30,
)

Two things deserve a note.

yolo26s.pt is not an empty model. It arrives with weights trained on COCO, which means it already knows edges, textures, shapes, and the general concept of where an object ends. We are only adapting the final layers to two new classes. This is fine-tuning, and it is the only reason 329 images can produce anything useful. Training from scratch would need tens of thousands.

The validation set is the 83 images the model never sees during training. It is the only honest answer to “is this any good”, because a model that has memorized its training data looks perfect on that data.

On an RTX 3090 this took five minutes and three seconds for 100 epochs.

What the curves said

The final numbers on the best checkpoint:

mAP50precisionrecall
overall0.6920.7850.642
matara0.7040.7480.707
pet_sise0.6790.8220.577

Unpacking those terms, because they measure genuinely different failures:

Precision is how often the model is right when it says something. 0.785 means about one in five detections is wrong.

Recall is how much of what exists the model actually finds. 0.642 means it misses about a third. pet_sise recall is 0.577, so it misses over 40 percent of plastic bottles, while its precision is 0.822. In plain terms: when it calls something a plastic bottle it is usually correct, but it lets a lot of them slip past.

mAP50 (“mean Average Precision at IoU 0.50”) is the summary score, computed by requiring a predicted box to overlap the true box by at least 50 percent to count as a hit. mAP50-95 averages the same measurement across overlap thresholds from 50 percent up to 95 percent, so it is a much stricter test of how tightly the boxes fit.

And here the training log had something interesting in it:

epochmAP50mAP50-95
10.2120.171
100.4950.363
290.7130.595
500.6550.563
750.6760.577
1000.6740.603

mAP50 peaked at epoch 29 and never got back there. mAP50-95 kept climbing, reaching its best at epoch 81.

The two metrics are describing different things and they stopped agreeing. After epoch 29 the model was not finding more bottles. It was drawing tighter boxes around the ones it already found. Loose-threshold performance had plateaued while strict-threshold performance was still improving.

This also explains why patience=30, which stops training after 30 epochs without improvement, never fired. Ultralytics does not track mAP50 for early stopping, it tracks a composite score weighted heavily toward mAP50-95. By that measure the model was still getting better, so training continued.

Is 0.692 good? Honestly, it is mediocre. It works, and you can see it work, but a production detector would want 0.85 or better. For 329 noisy scraped images and about two hours of total effort it is roughly what I would expect. The path to improving it is not a bigger model, it is more and cleaner data, particularly for pet_sise recall.

The bug that cost the most time

Training did not start on the first attempt. It crashed with this:

RuntimeError: CUDNN_BACKEND_TENSOR_DESCRIPTOR cudnnFinalize failed
ptrDesc->finalize() cudnn_status: CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH

cuDNN is NVIDIA’s library of GPU primitives for neural networks, the thing that actually performs a convolution on the hardware. If it does not load, nothing trains.

The driver was fine, 595.84 with CUDA 13.2, comfortably new enough. The problem was that cuDNN existed twice on that machine. The system had 9.25 installed under /lib/x86_64-linux-gnu, and the pip package inside the project’s virtual environment brought 9.20.

Modern cuDNN is not one file. It is split across sublibraries: libcudnn_ops, libcudnn_cnn, libcudnn_graph, libcudnn_engines_precompiled, and more. The dynamic linker resolved some from one location and some from the other, and the versions did not agree with each other. The error message says exactly that, once you know what a sublibrary is.

Listing both directories made it obvious, the system version shipped two libraries the pip version did not have at all:

$ ls /lib/x86_64-linux-gnu/libcudnn*.so.9 | xargs -n1 basename
libcudnn_adv.so.9
libcudnn_cnn.so.9
libcudnn_engines_precompiled.so.9
libcudnn_engines_runtime_compiled.so.9
libcudnn_engines_tensor_ir.so.9      # only in the system copy
libcudnn_ext.so.9                    # only in the system copy
libcudnn_graph.so.9
libcudnn_heuristic.so.9
libcudnn_ops.so.9
libcudnn.so.9

The fix was to make every sublibrary come from one consistent set, which is a one line environment variable:

export LD_LIBRARY_PATH=/lib/x86_64-linux-gnu

I pointed it at the system copy rather than removing it, deliberately. That machine also runs my local LLM stack, and uninstalling a system CUDA library to fix an unrelated project is how you break two things instead of one.

There was a second, smaller trap inside this one. My first instinct was to upgrade the pip package with uv pip install -U nvidia-cudnn-cu13. It appeared to work, and then torch still reported the old version. The reason is that uv run re-syncs the environment against the lockfile on every invocation, so my manual install was silently reverted before the script even started. In a uv project, installing a package by hand does not stick. It has to go through uv add so it lands in pyproject.toml.

Seeing it work

The last step was the point of the whole thing: hold a bottle up to a camera and watch a box appear around it.

The machine running the model is a Mac mini I reach remotely, so its own webcam was useless to me. Instead I wrote a small page that captures frames from the viewer’s browser camera, posts each frame to the server, and draws the returned boxes on a canvas overlay. The model runs on the Mac mini, the camera is wherever I am, including my phone.

One obstacle: browsers refuse getUserMedia, the camera API, on insecure connections. Plain HTTP over an IP address will not get camera access, no matter what the user clicks.

Tailscale solved this without any certificate work:

tailscale serve --bg 8010

The Python server keeps speaking plain HTTP on 127.0.0.1:8010. Tailscale puts a valid HTTPS endpoint in front of it on the tailnet. No port opened to the internet, no certificate generated by hand, and the camera API is satisfied.

Worth noting where the model ends up running: training happened on the RTX 3090, but inference runs on the Mac mini’s CPU and integrated GPU. best.pt is a single 19 MB file. Once training is done the expensive hardware is no longer part of the picture. Training is costly and happens once, inference is cheap and happens forever.

Detection takes 1.6 ms per image on the 3090, and comfortably enough on the Mac for a live camera feed.

Summary

The whole thing came to 1020 lines across seven files, and roughly two hours end to end, most of it spent on data rather than on the model.

Looking back, the parts that generalize beyond this toy problem:

  1. The model is the easy part. Training was one function call and five minutes. Everything else was getting 450 correct boxes to exist.

  2. Look at your data before you delete any of it. A two minute contact sheet caught a bug that would have silently cost me half the dataset, and I would never have known, because the training run would have completed successfully either way.

  3. Match the model size to the job, not to habit. Offline labeling should use the largest model available. Live inference should use the smallest one that works. I used the same reflex for both at first and it cost accuracy for no gain.

  4. A tool that does one thing can beat a tool that does everything. 509 lines of purpose-built triage turned three hours of general-purpose labeling into twenty five minutes.

  5. Metrics that usually move together are informative when they stop. mAP50 flattening while mAP50-95 kept rising is the difference between “finding more objects” and “drawing better boxes”, and the training curve showed exactly when the model switched from one to the other.

The detector is not good enough to ship, and it was never going to be. But it does work, it took an afternoon, and every part of why it works is visible.

Sources