DEV Community

Yiğit Erdoğan
Yiğit Erdoğan

Posted on

Detecting Objects in Satellite Imagery with YOLOv8: xView + DOTA to YOLO in Practice

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 .geojson file. Every object is a feature whose properties may hold bounds_imcoords, or bbox, or xmin/ymin/xmax/ymax, with inconsistent casing, and the images are 16-bit GeoTIFFs.
  • DOTA uses one .txt per 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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 that batch collapses to 4.
  • shear=0, perspective=0. Satellite views are roughly nadir. Simulating perspective distortion generates images the model will never see at inference.
  • copy_paste and mixup on. 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=True with a low lr0. 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")
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
zyvop profile image
ZyVOP

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.

Collapse
 
yigtwx profile image
Yiğit Erdoğan

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.

Collapse
 
zyvop profile image
ZyVOP

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.