Format conversion is the most routine thing in a data pipeline and one of the most common sources of a training run that mysteriously underperforms. The failures are quiet: nothing raises, the files look right, the model just learns less than it should.
The four things that go wrong
Across detection, segmentation and text, nearly every corrupted conversion falls into one of four buckets. Knowing them turns debugging from a search into a checklist.
- Coordinate convention. Corner versus centre, absolute versus normalised, and which corner is the origin. A corner/centre mix-up shifts each box by half its size, so it looks fine on small objects.
- Class index drift. Non-contiguous source ids, alphabetical reordering, or an off-by-one from a background class that one format reserves and the other does not.
- Lost negatives. Images with no objects vanish, because the converter iterates over annotations rather than over images. Your model loses its only signal about what absence looks like.
- Dropped metadata. Licence, source URL, split assignment, difficulty flags and per-sample provenance are not part of the geometry, so converters routinely discard them. You notice when someone asks whether you are allowed to ship the model.
Iterate over images, not annotations
This single change prevents the negatives bug. Build an index from image id to its annotations first, then walk the image list — so an image with an empty annotation list is a normal case rather than an absent one.
Driving the loop from images has one side effect: an annotation whose image id matches no image is never visited, so it silently disappears from the output. Building the index first gives you the place to catch those orphan references with an explicit assertion, which is how you catch a truncated or mismatched annotation file before it costs you a training run.
from collections import defaultdict
by_image = defaultdict(list)
for a in annotations:
by_image[a["image_id"]].append(a)
image_ids = {i["id"] for i in images}
orphans = [a for a in annotations if a["image_id"] not in image_ids]
assert not orphans, f"{len(orphans)} annotations reference missing images"
empty = 0
for im in images: # drive the loop from images
anns = by_image.get(im["id"], []) # empty is valid, not a skip
if not anns:
empty += 1
write_target_format(im, anns) # must handle the empty case
print(f"{len(images)} images written, {empty} with no objects")Round-trip, then compare
The strongest cheap check is a round trip. Convert A to B, convert B back to A, and compare against the original. Anything that does not survive the journey is something your conversion is losing.
You will not get an exact match, and that is informative. Float precision will differ after normalising and denormalising — compare with a tolerance of a pixel or so. Fields the target format cannot represent will come back missing, which tells you exactly what metadata to carry alongside rather than inside. Counts, though, should match exactly: same number of images, same number of objects per image, same class histogram.
| Check | Expectation |
|---|---|
| Image count | identical |
| Object count per image | identical, including zeros |
| Class histogram | identical after remapping |
| Box coordinates | within ~1px after round trip |
| Total mask area | within a fraction of a percent |
| Split assignment | identical — never re-split in a converter |
Draw it before you trust it
Numeric checks catch convention errors but not perceptual ones. After converting, render five or ten random samples with their new annotations drawn on and look at them. Deliberately include the image with the most objects, the one with the largest object, and one with no objects at all — the three cases where conventions break.
For text conversions the equivalent is printing the fully rendered training string, special tokens and all, for the longest and shortest examples in your set. Both checks take a couple of minutes and catch the class of problem that otherwise surfaces as a disappointing eval.
Keep the original
Convert into a new directory and never in place. Keep the source data, the conversion script and its version together, so the derived set is reproducible rather than merely present.
Write a short manifest next to the output: source path and checksum, script version, date, class mapping, and the count summary your converter printed. When a model trained six months ago needs explaining, that file is the difference between an answer and a shrug.
Questions people ask
What is the safest intermediate format to convert through?
COCO, for detection and segmentation. It represents more than YOLO or VOC can — polygons, crowd regions, keypoints, captions, per-image licences — so converting through it loses less than going through a narrower format. Just remember it cannot represent VOC's difficult flag natively.
How do I keep licence information through a conversion?
COCO has a licenses list and a per-image license id, which makes it one of the few mainstream annotation formats with somewhere to put it (Open Images also lists a licence per image). For formats without a slot, write a sidecar manifest keyed by filename and keep it with the data. Per-sample licensing matters most for mixed-source sets, where a single dataset-level licence is simply wrong.
Should I convert everything up front or on the fly?
Convert once, up front, for anything you will train on repeatedly — it is faster and the result is auditable. Convert on the fly only when the source is genuinely changing underneath you, and then log the conversion counts each run so a drift shows up.
My converted dataset trains worse than the original. Where do I look first?
In order: whether negatives survived (compare image counts, not annotation counts), whether the class mapping is identical, whether the split was preserved, then coordinate conventions on the largest objects. Those four account for most of it, and all are checkable in minutes against the source.