You labelled a few hundred frames as CSV rows — xmin, xmax, ymin, ymax and a class token — and the converter dies on the first file with IndexError: list index out of range at elems[1]. The annotations are fine; the parsing is not. This guide is for anyone moving a labelled CSV set into a YOLO trainer, whether the script runs on your own machine or a hosted notebook. You get the three faults behind the crash in the order they surface — delimiter, line endings, unset image path — the corrected loop with a test that proves its output, and the arithmetic that turns one pixel row into a valid YOLO line. Nothing needs re-labelling; fix the converter and re-run it over the same files.
What the traceback is telling you
The script's last printed lines are the whole diagnosis:
Input:labels/stopsign_original/out19.csv Output:labels/stopsign/out19.csv
47,103,470,581,tops
['47,103,470,581,tops\n']
File "convert.py", line 78, in <module>
xmax = elems[1]
IndexError: list index out of range
elems = line.split(' ') splits the row on spaces. The row contains none, so the split returns a list holding one string — the printed ['47,103,470,581,tops\n'] — and elems[1] does not exist. The guard above it, if(len(line) >= 2), cannot catch this: the string is 19 characters plus the trailing newline, long enough to pass any length test while still having no spaces to split on. So the label file is comma-delimited and the script assumes space-delimited values. That mismatch is fault one; two more wait behind it.
Cause 1: comma rows meet a space-delimited split
Test. Print the row with repr(line) instead of print(line): it shows '47,103,470,581,tops\n' — commas present, no spaces. That single check separates this fault from every other parsing bug.
Fix. Strip the newline, then split on the comma:
elems = line.strip().split(",") # -> ['47', '103', '470', '581', 'tops']
The row now yields five fields. The first four convert with float() and keep the file's order — the script's b = (float(xmin), float(xmax), float(ymin), float(ymax)) expects xmin, xmax, ymin, ymax, not the xmin, ymin, xmax, ymax order many labelling tools export. Check which order your file uses before wiring the arithmetic.
The class token is a second trap. These rows carry tops while the script's list is classes = ["stopsign"], so classes.index("tops") would raise ValueError on the next run. Either extend the list to cover every token the CSV actually contains, or, for a one-class set, drop the token and write the id 0 directly.
Cause 2: line endings the split never catches
The script builds its rows with txt_file.read().split('\r\n'), splitting the whole file on the Windows two-character ending. The printed element still ends in \n, which proves this file carries Unix one-character endings: the split key never matched. On a multi-row file the entire CSV arrives as one giant "line", and only the first row's numbers survive into the output.
Read the file with splitlines() instead — it handles \r\n, \n and \r endings and drops the empty row a trailing newline otherwise leaves behind:
lines = txt_file.read().splitlines()
Then replace if(len(line) >= 2) with a check on parsed fields, if len(elems) < 4: continue. A length test passes any stray characters; a field-count test passes only real box rows, so header rows, blank lines and annotation-tool notes fall through to a skip instead of a crash.
Cause 3: img_path is never assigned
Clear the first two faults and the script fails one line later. The img_path assignment sits commented out in the code, so im = Image.open(img_path) raises NameError: name 'img_path' is not defined. Rebuild the path from the annotation stem, pointing where the list file already points:
img_path = "%s/images/%s/%s.JPEG" % (wd, cls, os.path.splitext(txt_name)[0])
out19.csv then opens images/stopsign/out19.JPEG. Read the size per frame, not once for the whole set:
im = Image.open(img_path)
w, h = im.size # ints, e.g. (1280, 720) on the illustrative frame below
The size matters because normalization divides by it. A set captured at mixed resolutions needs each row divided by its own frame's size, and this per-file read provides exactly that.
The YOLO line the converter must write
YOLO expects one text file per image, named after the image, holding one line per object:
class_id x_center y_center width height
class_id is the object's index in your classes list. The other four values are the box converted from pixel corners to center-and-size form, then divided by the image dimension on each axis so every value lands between 0 and 1:
The formula assumes pixel coordinates with the origin at the top-left and y increasing downwards, which is what CSV exports from labelling tools carry. Write about six decimals and keep the CSV as your source of truth, so a later re-conversion at a different resolution starts from real pixels.
Worked example: row 47,103,470,581,tops on a 1280 × 720 frame
Inputs and where each comes from: xmin = 47 px, xmax = 103 px, ymin = 470 px, ymax = 581 px and the class token tops (id 0) come straight from the row in out19.csv. The frame size is not in the CSV; it comes from the matched image, read as im.size. For the arithmetic below take a 1280 × 720 frame — substitute your own im.size values, the method does not change.
Rule in words: the center is the midpoint of the two corners on each axis, the size is the corner difference, and each result is divided by the image dimension on the same axis.
Arithmetic:
- x_center = (47 + 103) / 2 = 75 px; 75 / 1280 = 0.0586
- y_center = (470 + 581) / 2 = 525.5 px; 525.5 / 720 = 0.7299
- width = 103 − 47 = 56 px; 56 / 1280 = 0.0438
- height = 581 − 470 = 111 px; 111 / 720 = 0.1542
The converted line:
0 0.0586 0.7299 0.0438 0.1542
Decision: all four values lie between 0 and 1 and both sizes are positive, so the row is valid — write it. Any value above 1 means stop and fix the divisor: you divided by the other axis, by a resized frame's size, or skipped the division entirely.
The corrected loop, and the test that proves it
The parsing half of the loop, with the illustrative row declared so the block runs on its own. The output file here is named from the stem — labels/stopsign/out19.txt — rather than the script's labels/stopsign/out19.csv; the checks below explain why.
from PIL import Image # Pillow, documented API
classes = ["stopsign"] # list[str]; class ids are list positions
# inside: for line in lines: — one row (str), e.g. "47,103,470,581,tops"
# img_path (str) built per file as in the section above
elems = line.strip().split(",") # -> ['47', '103', '470', '581', 'tops']
if len(elems) < 4: # header, blank or note row
continue
xmin, xmax, ymin, ymax = (float(v) for v in elems[:4])
cls_id = 0 # one-class set: the 'tops' token maps to 0
im = Image.open(img_path)
w, h = im.size # ints, e.g. (1280, 720)
x = (xmin + xmax) / 2.0 / w
y = (ymin + ymax) / 2.0 / h
bw = (xmax - xmin) / w
bh = (ymax - ymin) / h
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 and 0.0 < bw <= 1.0 and 0.0 < bh <= 1.0
txt_outfile.write("%d %.6f %.6f %.6f %.6f\n" % (cls_id, x, y, bw, bh))
Test that proves the result: run this block on the worked row against the 1280 × 720 frame, then read the output back.
print(open("labels/stopsign/out19.txt").read())
# -> 0 0.058594 0.729861 0.043750 0.154167
The four fields match the hand arithmetic — 75 / 1280 = 0.058594 at six decimals, 525.5 / 720 = 0.729861, 56 / 1280 = 0.043750, 111 / 720 = 0.154167 — and each lies between 0 and 1, which is what the assertion enforced.
Checks before you train
Run these once over the whole converted set, not just the first file. Partly hidden objects convert the same as clean ones — pixel corners are pixel corners — and Occluded object bounding box labeling for training covers the annotation decisions upstream.
-
Pairing. One label file per image with the image's basename:
out19.jpgpairs without19.txt. Build the name from the stem —os.path.splitext(txt_name)[0] + ".txt"— because loaders that key labels by basename ignore files still carrying.csv. - Empty frames. An image with no objects stays in the set; Background images in object detection training: why no labels explains why negatives need no boxes.
- Range. Every coordinate between 0 and 1; a value above 1 points at a wrong divisor or a pixel value that skipped normalization.
- Counts. Lines written per file equals rows parsed, and the set total matches the object count your labelling tool reports.
-
Class ids. Every id below
len(classes); a stray id crashes training far from its cause. -
One resolution end to end. Divisors came from each matched frame's
im.size; a resize between capture and training invalidates all of them. Capture, annotate and deploy at one resolution — fixed-resolution inspection cameras live in the area scan cameras and smart cameras categories. -
Draw three back. Overlay converted boxes on a few frames with Pillow's ImageDraw: a rectangle from
((x - bw/2) * w, (y - bh/2) * h)to((x + bw/2) * w, (y + bh/2) * h). Boxes on the objects prove the conversion; boxes anywhere else show which row broke.
When the delimiter is not the fault
Four cases produce the same traceback shape but need different arithmetic:
- Values already below 1. The file is normalized — likely a YOLO-shaped export missing only the class id, or with a different field order. Dividing again shrinks every box towards a point. Check the column's largest value before converting.
- Fields are x, y, w, h. Corner math does not apply. Use x_center = (x + w / 2) / image_width and y_center = (y + h / 2) / image_height; width and height divide directly.
- One manifest for the whole set. A single CSV with an image-name column is a different converter: every row needs its image's name and size. That is the job covered in Convert JSON bounding box annotations for vision training — same normalization, different container.
- Rotated boxes or polygons. These do not fit the five-field YOLO line; converting them as axis-aligned rectangles silently drops the rotation, so pick a format that keeps the geometry.
Once the set converts, the next consumer is the training input pipeline: Object detector input dimensions: height, width, and padding covers what happens to normalized boxes when the network resizes and pads frames. To build or upgrade the capture side, the camera finder filters by resolution, shutter and interface, and the system configurator walks a full inspection set from the camera onward.
Questions
Does YOLO need one label file per image?
Yes. The trainer expects one text file per image with the same basename — out19.jpg pairs with out19.txt — holding one line per object. An image with no objects still belongs in the set: keep it with an empty label file or none at all, but make the choice consistent, because a stray missing file reads as background by accident. Background frames without boxes are deliberate training data, not errors to hunt down.
How do I know whether my CSV corners are xmin, xmax, ymin, ymax?
The converter maps field 1 to xmin, field 2 to xmax, field 3 to ymin, field 4 to ymax. Many labelling tools export xmin, ymin, xmax, ymax instead. Confirm on one frame: this row spans 103 − 47 = 56 px horizontally and 581 − 470 = 111 px vertically under the converter's order, so a stop sign taller than wide matches it, while a wider-than-tall object means the pairs are swapped and you reorder before converting. Wrong pairing fails silently — the trainer accepts every box.
What does it mean when a converted YOLO value is greater than 1?
A normalized coordinate above 1 means the wrong divisor: you divided by the other axis, by a resized image's size, or skipped the division and wrote pixels. Check three things — width against the image width, height against the image height, and that xmax is not smaller than xmin. Fix the divisor and re-run; negative or inverted boxes will not sort themselves out during training.
My CSV has a header row — will the converter take it?
A header such as xmin,xmax,ymin,ymax,label fails the float conversion, so guard the loop: parse each field inside a try block around float(), or skip the first line of every file. The same field-count guard catches blank lines left by the trailing newline and any note rows an annotation tool appends, so one check covers all three.
How many decimals should the YOLO line keep?
Six decimals is the common choice and keeps sub-pixel error negligible: at 1280 pixels wide, one unit in the sixth decimal moves a box edge by about a thousandth of a pixel. Three decimals already bounds the error to roughly half a pixel at that width. Write with a fixed format string and keep the pixel CSV as your source of truth for re-conversions.