Satellite imagery breaks most of the assumptions object detectors are built on. Objects are tiny (a car is 10–15 px in a 3000×3000 tile), classes are wildly imbalanced, and the annotations don't come in anything close to YOLO format.
I spent a while building dl_xview_yolo — a YOLOv8 pipeline for detecting planes, ships, vehicles, bridges and storage tanks in aerial and satellite images, trained on xView and DOTA. This post is about the parts that actually took time: the data conversion, the training config, and the mistakes worth avoiding.
The real work is the data conversion
Nobody tells you that ~80% of a detection project is reshaping labels. The two datasets couldn't be more different:
-
xView ships a single giant
.geojsonfile. Every object is a feature whosepropertiesmay holdbounds_imcoords, orbbox, orxmin/ymin/xmax/ymax, with inconsistent casing, and the images are 16-bit GeoTIFFs. -
DOTA uses one
.txtper image, with oriented boxes: 8 numbers (four corner points) plus a class name.
So convert_all_to_yolo.py ended up being a tolerant parser rather than a clean one:
def extract_bbox_from_props(props: dict):
# 1) bounds_imcoords: "x1,y1,x2,y2"
for key in props:
lk = key.lower()
if "bounds" in lk and "imcoords" in lk:
bb = parse_bounds_string(props[key])
if bb:
return bb
# 2) bbox as list or string
# 3) xmin/ymin/xmax/ymax, x_min/..., left/top/right/bottom
Two things I'd do the same way again:
Normalize the imagery before anything else. GeoTIFFs are frequently 16-bit or single-channel, and cv2.imread will happily hand you an array OpenCV's JPEG writer chokes on:
def ensure_bgr_uint8(im):
if im.dtype != np.uint8:
im = cv2.normalize(im, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
if im.ndim == 2:
im = cv2.cvtColor(im, cv2.COLOR_GRAY2BGR)
return im
Convert oriented boxes to axis-aligned early. DOTA's polygons become horizontal boxes via min/max on the corner coordinates — a real information loss for rotated ships and bridges, but it lets you train plain yolov8m instead of the OBB head. I started on yolov8m-obb.pt and dropped back to detection; the pipeline got dramatically simpler and the mAP was good enough for the use case.
xs = list(map(float, parts[0:8:2]))
ys = list(map(float, parts[1:8:2]))
xmin, ymin, xmax, ymax = min(xs), min(ys), max(xs), max(ys)
Everything then lands in the standard layout — images/{train,val} + labels/{train,val} + data.yaml — and clipped boxes outside [0, 1] are dropped rather than clamped-and-kept, which quietly removes a class of degenerate labels.
Training config that survived contact with a single GPU
The full detection run:
model.train(
data=str(DATA_YAML),
epochs=100,
imgsz=1024, # small objects need resolution, not more epochs
batch=4, # 1024px is expensive; batch stays tiny
optimizer="AdamW",
lr0=0.0001,
cos_lr=True,
dropout=0.05,
hsv_h=0.015, hsv_s=0.75, hsv_v=0.45,
translate=0.15, scale=0.55,
mixup=0.15, copy_paste=0.15,
shear=0.0, perspective=0.0, # nadir imagery: no perspective to simulate
close_mosaic=30,
patience=30,
)
The reasoning behind the non-obvious ones:
-
imgsz=1024, not 640. This is the single highest-impact knob for aerial data. Downscaling to 640 makes small vehicles disappear into a couple of pixels. The cost is thatbatchcollapses to 4. -
shear=0, perspective=0. Satellite views are roughly nadir. Simulating perspective distortion generates images the model will never see at inference. -
copy_pasteandmixupon. Class imbalance in xView is brutal; pasting rare instances into other tiles is cheaper than resampling. -
close_mosaic=30. Mosaic augmentation is great early and harmful at the end — the last 30 epochs train on real, unstitched tiles. -
cos_lr=Truewith a lowlr0. Fine-tuning from COCO weights onto a domain this different punishes aggressive learning rates.
Results on the validation split:
| Metric | Value |
|---|---|
| mAP@0.5 | 0.54 |
| mAP@0.5-0.95 | 0.36 |
| Precision | 0.67 |
| Recall | 0.71 |
Not state of the art, but a usable baseline — and the gap between mAP@0.5 and mAP@0.5:0.95 is exactly what you'd expect when boxes are small: localization is approximate even when detection is correct.
Serving it: one script, CLI and UI
predict_yolo.py does double duty. With --no-ui it's a plain batch inference script; without it, it boots a FastAPI app serving a drag-and-drop page that posts an image and gets back an annotated PNG:
@app.post("/predict")
async def predict_endpoint(file: UploadFile = File(...)):
img = Image.open(io.BytesIO(await file.read())).convert("RGB")
results = model.predict(source=np.array(img), imgsz=imgsz, conf=conf, device=device)
return StreamingResponse(io.BytesIO(ndarray_to_png_bytes(results[0].plot())),
media_type="image/png")
Small detail that saves real time: the weights are discovered automatically instead of hardcoded.
bests = sorted(glob.glob(str(RUNS / "train" / "**" / "weights" / "best.pt"), recursive=True))
After twenty training runs you stop remembering which exp folder holds the good checkpoint.
Three things I'd tell my past self
1. Make your train/val split deterministic — and verify it. I split by hashing the filename:
def is_val_split(name: str) -> bool:
return (hash(name) % 10) < 2
Python's hash() for strings is salted per process unless PYTHONHASHSEED is fixed. Re-run the conversion and images silently move between train and val — which means a model evaluated after a re-conversion may have trained on its own validation set. Use a stable hash instead:
import hashlib
def is_val_split(name: str) -> bool:
digest = hashlib.md5(name.encode()).hexdigest()
return int(digest, 16) % 10 < 2
2. Unify your class taxonomy before merging datasets. xView maps type_id - 1 into a 60-class space; DOTA maps 15 class names into indices 0–14. Write both into the same labels/ directory and DOTA's plane collides with whatever xView class index 0 happens to be. Decide on one taxonomy — or keep the datasets in separate runs — before you spend GPU hours.
3. Don't hardcode absolute paths. Mine started as C:\Users\Asus\Desktop\dl_xview and it's the first thing anyone cloning the repo has to fix. Read roots from a config file or an environment variable from day one.
Try it
git clone https://github.com/Yigtwxx/dl_xview_yolo.git
cd dl_xview_yolo
pip install ultralytics opencv-python pillow tqdm numpy torch torchvision fastapi uvicorn
python scripts/convert_all_to_yolo.py # xView/DOTA → YOLO
python scripts/tain_yolo.py --epochs 100 # train
python scripts/val_yolo.py # evaluate
python scripts/predict_yolo.py # FastAPI UI at 127.0.0.1:7860
Repo: github.com/Yigtwxx/dl_xview_yolo — MIT licensed, issues and PRs welcome. If you've trained detectors on aerial imagery, I'd genuinely like to hear how you handled the tiny-object problem: tiling, higher resolution, or a different architecture entirely.
Top comments (3)
The article's step-by-step guide on converting xView GeoJSON and DOTA oriented boxes to YOLO format is particularly impressive, making it easier for readers to replicate the process and build their own satellite object detection pipeline.
Thanks, glad that part was useful! The conversion step is honestly where most of the time went - xView's property naming is inconsistent enough (bounds_imcoords, bbox, xmin/ymin/...) that a strict parser silently drops labels instead of failing loudly, so the tolerant parser wasn't optional.
One tradeoff worth flagging if you reuse the code: collapsing DOTA's oriented boxes to axis-aligned ones keeps the YOLOv8 head simple, but it costs you on elongated, rotated objects - docked ships and harbors especially, where the AABB swallows a lot of background. If your target classes lean that way, yolov8-obb is probably a better starting point than my converter.
Happy to answer anything on the tiling or the 1024px / batch-4 setup if you end up running it.
No problem 😀, if you're interested in sharing your expertise with a broader audience, consider cross-posting your content to ZyVOP, where you can reach a community of like-minded developers and grow your readership.