Categories
AI Programming Python

Ditching Ultralytics: Training YOLOX for Aircraft Detection

I’ve been building SkySpottr, an AR app overlaying aircraft information on your phone’s screen, using your device’s location, orientation, and incoming aircraft data (ADS-B) to predict where planes should appear on screen, then uses a YOLO model to lock onto the actual aircraft and refine the overlay. YOLOv8 worked great for this… until I actually read the license.

Welcome to Austin’s Nerdy Things, where we train from scratch entire neural networks to avoid talking to lawyers.

The Problem with Ultralytics

YOLOvWhatver is excellent. Fast, accurate, easy to use, great documentation. But Ultralytics licenses it under AGPL-3.0, which means if you use it in a product, you either need to open-source your entire application or pay for a commercial license. For a side project AR app that I might eventually monetize? That’s a hard pass.

Enter YOLOX from Megvii (recommended by either ChatGPT or Claude, can’t remember which, as an alternative). MIT licensed. Do whatever you want with it. The catch? You have to train your own models from scratch instead of using Ultralytics’ pretrained weights and easy fine-tuning pipeline. I have since learned there are some pretrained models. I didn’t use them.

So training from scratch is what I did. Over a few late nights in December 2025, I went from zero YOLOX experience to running custom-trained aircraft detection models in my iOS app. Here’s how it went.

The Setup

Hardware: RTX 3090 on my Windows machine, COCO2017 dataset on network storage (which turned out to be totally fine for training speed), and way too many terminal windows open.

I started with the official YOLOX repo and the aircraft class from COCO2017. The dataset has about 3,000 training images with airplanes, which is modest but enough to get started.

git clone https://github.com/Megvii-BaseDetection/YOLOX
pip install -v -e .

The first training run failed immediately because I forgot to install YOLOX as a package. Classic. Then it failed again because I was importing a class that didn’t exist in the version I had. Claude (who was helping me through this, and hallucinated said class) apologized and fixed the import. We got there eventually.

Training Configs: Nano, Tiny, Small, and “Nanoish”

YOLOX has a nice inheritance-based config system. You create a Python file, inherit from a base experiment class, and override what you want. I ended up with four different configs:

  • yolox_nano_aircraft.py – The smallest. 0.9M params, 1.6 GFLOPs. Runs on anything.
  • yolox_tiny_aircraft.py – Slightly bigger with larger input size for small object detection.
  • yolox_small_aircraft.py – 5M params, 26 GFLOPs. The “serious” model.
  • yolox_nanoish_aircraft.py – My attempt at something between nano and tiny.

The “nanoish” config was my own creation where I tried to find a sweet spot. I bumped the width multiplier from 0.25 to 0.33 and… immediately got a channel mismatch error because 0.33 doesn’t divide evenly into the architecture. Turns out you can’t just pick arbitrary numbers. I am a noob at these things. Lesson learned.

After some back-and-forth, I settled on a config with 0.3125 width (which is 0.25 \* 1.25, mathematically clean) and 512×512 input. This gave me roughly 1.2M params – bigger than nano, smaller than tiny, and it actually worked.

Here’s the small model config – the one that ended up in production. The key decisions are width = 0.50 (2x wider than nano for better feature extraction), 640×640 input for small object detection, and full mosaic + mixup augmentation:

class Exp(MyExp):
    def __init__(self):
        super(Exp, self).__init__()

        # Model config - YOLOX-Small architecture
        self.num_classes = 1  # Single class: airplane
        self.depth = 0.33
        self.width = 0.50  # 2x wider than nano for better feature extraction

        # Input/output config - larger input helps small object detection
        self.input_size = (640, 640)
        self.test_size = (640, 640)
        self.multiscale_range = 5  # Training will vary from 480-800

        # Data augmentation
        self.mosaic_prob = 1.0
        self.mosaic_scale = (0.1, 2.0)
        self.enable_mixup = True
        self.mixup_prob = 1.0
        self.flip_prob = 0.5
        self.hsv_prob = 1.0

        # Training config
        self.warmup_epochs = 5
        self.max_epoch = 400
        self.no_aug_epochs = 100
        self.basic_lr_per_img = 0.01 / 64.0
        self.scheduler = "yoloxwarmcos"

    def get_model(self):
        from yolox.models import YOLOX, YOLOPAFPN, YOLOXHead

        in_channels = [256, 512, 1024]
        # Small uses standard convolutions (no depthwise)
        backbone = YOLOPAFPN(self.depth, self.width, in_channels=in_channels, act=self.act)
        head = YOLOXHead(self.num_classes, self.width, in_channels=in_channels, act=self.act)
        self.model = YOLOX(backbone, head)
        return self.model

And the nanoish config for comparison – note the depthwise=True and the width of 0.3125 (5/16) that I landed on after the channel mismatch debacle:

class Exp(MyExp):
    def __init__(self):
        super(Exp, self).__init__()

        self.num_classes = 1
        self.depth = 0.33
        self.width = 0.3125  # 5/16 - halfway between nano (0.25) and tiny (0.375)

        self.input_size = (512, 512)
        self.test_size = (512, 512)

        # Lighter augmentation than small - this model is meant to be fast
        self.mosaic_prob = 0.5
        self.mosaic_scale = (0.5, 1.5)
        self.enable_mixup = False

    def get_model(self):
        from yolox.models import YOLOX, YOLOPAFPN, YOLOXHead

        in_channels = [256, 512, 1024]
        backbone = YOLOPAFPN(self.depth, self.width, in_channels=in_channels,
                             act=self.act, depthwise=True)  # Depthwise = lighter
        head = YOLOXHead(self.num_classes, self.width, in_channels=in_channels,
                         act=self.act, depthwise=True)
        self.model = YOLOX(backbone, head)
        return self.model

Training is then just:

python tools/train.py -f yolox_small_aircraft.py -d 1 -b 16 --fp16 -c yolox_s.pth

The -c yolox_s.pth loads YOLOX’s pretrained COCO weights as a starting point (transfer learning). The -d 1 is one GPU, -b 16 is batch size 16 (about 8GB VRAM on the 3090 with fp16), and --fp16 enables mixed precision training.

The Small Object Problem

Here’s the thing about aircraft detection for an AR app: planes at cruise altitude look tiny. A 747-8 at 37,000 feet is maybe 20-30 pixels on your phone screen if you’re lucky, even with the 4x optical zoom of the newest iPhones (8x for the 12MP weird zoom mode). Standard YOLO models are tuned for reasonable-sized objects, not specks in the sky. The COCO dataset has aircraft that are reasonably sized, like when you’re sitting at your gate at an airport and take a picture of the aircraft 100 ft in front of you.

My first results were underwhelming. The nano model was detecting larger aircraft okay but completely missing anything at altitude. The evaluation metrics looked like this:

AP for airplane = 0.234
AR for small objects = 0.089

Not great. The model was basically only catching aircraft on approach or takeoff.

For the small config, I made some changes to help with tiny objects:

  • Increased input resolution to 640×640 (more pixels = more detail for small objects)
  • Enabled full mosaic and mixup augmentation (helps the model see varied object scales)
  • Switched from depthwise to regular convolutions (more capacity)
  • (I’ll be honest, I was leaning heavily on Claude for the ML-specific tuning decisions here)

This pushed the model to 26 GFLOPs though, which had me worried about phone performance.

Here’s what the small model’s accuracy looked like broken down by object size. You can see AP for small objects climbing from ~0.45 to ~0.65 over training, while large objects hit ~0.70. Progress, but small objects remain the hardest category – which tracks with the whole “specks in the sky” problem.

YOLOX-Small mAP by IoU threshold and object size

Will This Actually Run on a Phone?

The whole point of this exercise was to run inference on an iPhone. So here is some napkin math:

ModelGFLOPsEstimated Phone Inference
Nano1.6~15ms, smooth 30fps easy
Nanoish3.2~25ms, still good
Small26~80ms, might be sluggish
YOLOv8n (for reference)8.7~27ms

My app was already running YOLOv8n at 15fps with plenty of headroom. So theoretically even the small model should work, but nano/nanoish would leave more room for everything else the app needs to do.

The plan: train everything, compare accuracy, quantize for deployment, and see what actually works in practice.

Training Results (And a Rookie Mistake)

After letting things run overnight (300 epochs takes a while even on a 3090), here’s what I got:

The nanoish model at epoch 100 was already showing 94% detection rate on test images, beating the fully-trained nano model. And it wasn’t even done training yet.

Quick benchmark on 50 COCO test images with aircraft (RTX 3090 GPU inference – not identical to phone, but close enough for the smaller models to be representative):

ModelDetection RateAvg Detections/ImageAvg Inference (ms)FPS
YOLOv8n58.6%0.8233.629.7
YOLOX nano74.3%1.0414.071.4
YOLOX nanoish81.4%1.1415.066.9
YOLOX tiny91.4%1.2816.560.7
YOLOX small92.9%1.3017.457.4
Ground Truth1.40

YOLOv8n getting beaten by every single YOLOX variant while also being slower was… not what I expected. Here’s the mAP comparison across all the models over training – you can see the hierarchy pretty clearly:

mAP comparison across all YOLOX model variants

The big takeaway: more capacity = better accuracy, but with diminishing returns. The jump from nano to nanoish is huge, nanoish to small is solid, and tiny lands somewhere in between depending on the epoch. (You’ll notice two extra lines in the chart – a large model and a self-sourced variant. I kept training after this post’s story ends. More on the self-sourced pipeline later. You can also see the large model is clearly overfitting past epoch ~315 – loss keeps decreasing but mAP starts dropping. My first time overfitting a model.)

The nanoish model hit a nice sweet spot. Faster than YOLOv8n, better small object detection than pure nano, and still lightweight enough for mobile.

And here is the output from my plot_training.py script:

============================================================
SUMMARY
============================================================
Run                         Epochs   Final Loss    Best AP  Best AP50
------------------------------------------------------------
yolox_large_aircraft           391       0.6000     0.6620     0.8620
yolox_nano_aircraft            300       3.3000     0.4770     0.7390
yolox_nanoish_aircraft         142       4.3000     0.4390     0.7210
yolox_small_aircraft           302       2.2000     0.6360     0.8650
yolox_small_with_self_sou      400       1.4000     0.6420     0.8620
yolox_tiny_aircraft            300       2.5000     0.6060     0.8480
============================================================

====================================================================================================
mAP VALUES AT SPECIFIC EPOCHS
====================================================================================================
Run                           AP@280     AP50@280    APsmall@280     AP@290     AP50@290    APsmall@290     AP@299     AP50@299    APsmall@299
----------------------------------------------------------------------------------------------------
yolox_large_aircraft          0.6350       0.8410         0.6690     0.6390       0.8410         0.6750 0.6480(300)       0.8440         0.6780
yolox_nano_aircraft           0.4750       0.7360         0.4000     0.4740       0.7360         0.3970     0.4770       0.7380         0.4030
yolox_nanoish_aircraft           N/A          N/A            N/A        N/A          N/A            N/A        N/A          N/A            N/A
yolox_small_aircraft          0.5900       0.8440         0.5960     0.6230       0.8610         0.6340     0.6360       0.8630         0.6410
yolox_small_with_self_sou     0.5940       0.8430         0.5690     0.5900       0.8420         0.5660 0.5930(300)       0.8420         0.5630
yolox_tiny_aircraft           0.5800       0.8300         0.5650     0.5950       0.8340         0.5830     0.6060       0.8440         0.5780
====================================================================================================

But there was a problem I didn’t notice until later: my training dataset had zero images without aircraft in them. Every single training image contained at least one airplane. This is… not ideal if you want your model to learn what an airplane isn’t. More on that shortly.

How It Actually Works in the App

Before I get to results, here’s what the ML is actually doing in SkySpottr. The app combines multiple data sources to track aircraft:

  1. ADS-B data tells us where aircraft are in 3D space (lat, lon, altitude)
  2. Device GPS and orientation tell us where the phone is and which way it’s pointing
  3. Physics-based prediction places aircraft overlays on screen based on all the above

That prediction is usually pretty good, but phone sensors drift and aircraft positions are slightly delayed. So the overlays can be off by a couple degrees. This is where YOLO comes in.

The app runs the model on each camera frame looking for aircraft. When it finds one within a threshold distance of where the physics engine predicted an aircraft should be, it “snaps” the overlay to the actual detected position. The UI shows an orange circle around the aircraft and marks it as “SkySpottd” – confirmed via machine learning.

I call this “ML snap” mode. It’s the difference between “there’s probably a plane somewhere around here” and “that specific bright dot is definitely the aircraft.”

The model runs continuously on device, which is why inference time matters so much. Even at 15fps cap, that’s still 15 inference cycles per second competing with everything else the app needs to do (sensor fusion, WebSocket data, AR rendering, etc.). Early on I was seeing 130%+ CPU usage on my iPhone, which is not great for battery life. Every millisecond saved on inference is a win.

Getting YOLOX into CoreML

One thing the internet doesn’t tell you: YOLOX and Apple’s Vision framework don’t play nice together.

YOLOv8 exports to CoreML with a nice Vision-compatible interface. You hand it an image, it gives you detections. Easy. YOLOX expects different preprocessing – it wants pixel values in the 0-255 range (not normalized 0-1), and the output tensor layout is different.

The conversion pipeline goes PyTorch → TorchScript → CoreML. Here’s the core of it:

import torch
import coremltools as ct
from yolox.models import YOLOX, YOLOPAFPN, YOLOXHead

# Build model (same architecture as training config)
backbone = YOLOPAFPN(depth=0.33, width=0.50, in_channels=[256, 512, 1024], act="silu")
head = YOLOXHead(num_classes=1, width=0.50, in_channels=[256, 512, 1024], act="silu")
model = YOLOX(backbone, head)

# Load trained weights
ckpt = torch.load("yolox_small_best.pth", map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model"])
model.eval()
model.head.decode_in_inference = True  # Output pixel coords, not raw logits

# Trace and convert
dummy = torch.randn(1, 3, 640, 640)
traced = torch.jit.trace(model, dummy)
mlmodel = ct.convert(
    traced,
    inputs=[ct.TensorType(name="images", shape=(1, 3, 640, 640))],
    outputs=[ct.TensorType(name="output")],
    minimum_deployment_target=ct.target.iOS15,
    convert_to="mlprogram",
)
mlmodel.save("yolox_small_aircraft.mlpackage")

The decode_in_inference = True is crucial — without it, the model outputs raw logits and you’d need to implement the decode head in Swift. With it, the output is [1, N, 6] where 6 is [x_center, y_center, width, height, obj_conf, class_score] in pixel coordinates.

On the Swift side, Claude ended up writing a custom detector that bypasses the Vision framework entirely. Here’s the preprocessing — the part that was hardest to get right:

/// Convert pixel buffer to MLMultiArray [1, 3, H, W] with 0-255 range
private func preprocess(pixelBuffer: CVPixelBuffer) -> MLMultiArray? {
    // GPU-accelerated resize via Core Image
    let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
    let scaleX = CGFloat(inputSize) / ciImage.extent.width
    let scaleY = CGFloat(inputSize) / ciImage.extent.height
    let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))

    // Reuse pixel buffer from pool (memory leak fix #1)
    var resizedBuffer: CVPixelBuffer?
    CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &resizedBuffer)
    guard let buffer = resizedBuffer else { return nil }
    ciContext.render(scaledImage, to: buffer)

    // Reuse pre-allocated MLMultiArray (memory leak fix #2)
    guard let array = inputArray else { return nil }

    CVPixelBufferLockBaseAddress(buffer, .readOnly)
    defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }

    let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer)
    let pixels = CVPixelBufferGetBaseAddress(buffer)!.assumingMemoryBound(to: UInt8.self)
    let arrayPtr = array.dataPointer.assumingMemoryBound(to: Float.self)
    let channelStride = inputSize * inputSize

    // BGRA → RGB, keep 0-255 range (YOLOX expects unnormalized pixels)
    // Direct pointer access is ~100x faster than MLMultiArray subscript
    for y in 0..<inputSize {
        let rowOffset = y * bytesPerRow
        let yOffset = y * inputSize
        for x in 0..<inputSize {
            let px = rowOffset + x * 4
            let idx = yOffset + x
            arrayPtr[idx] = Float(pixels[px + 2])                      // R
            arrayPtr[channelStride + idx] = Float(pixels[px + 1])      // G
            arrayPtr[2 * channelStride + idx] = Float(pixels[px])      // B
        }
    }
    return array
}

The two key gotchas: (1) BGRA byte order from the camera vs RGB that the model expects, and (2) YOLOX wants raw 0-255 pixel values, not the 0-1 normalized range that most CoreML models expect. If you normalize, everything silently breaks — the model runs, returns garbage, and you spend an evening wondering why.

For deployment, I used CoreML’s INT8 quantization (coremltools.optimize.coreml.linear_quantize_weights). This shrinks the model by about 50% with minimal accuracy loss. The small model went from ~17MB to 8.7MB, and inference time improved slightly.

Real World Results (Round 1)

I exported the nanoish model and got it running in SkySpottr. The good news: it works. The ML snap feature locks onto aircraft, the orange verification circles appear, and inference is fast enough that I don’t notice any lag.

The less good news: false positives. Trees, parts of houses, certain cloud formations – the model occasionally thinks these are aircraft. Remember that rookie mistake about no negative samples? Yeah.

I later set up a 3-way comparison to visualize exactly this kind of failure. The three panels show my COCO-only trained model (red boxes), a later model trained on self-sourced images (green boxes – I’ll explain this pipeline shortly), and YOLO26-X as a ground truth oracle (right panel, no boxes means no detection). The COCO-only model confidently detects an “aircraft” that is… a building. The other two correctly ignore it.

False positive comparison - COCO-only model detects a building as aircraft

The app handles this gracefully because of the matching threshold. Random false positives in empty sky don’t trigger the snap because there’s no predicted aircraft nearby to match against. But when there’s a tree branch right next to where a plane should be, the model sometimes locks onto the wrong thing.

The even less good news: it still struggles with truly distant aircraft. A plane at 35,000 feet that’s 50+ miles away is basically a single bright pixel. No amount of ML is going to reliably detect that. For those, the app falls back on pure ADS-B prediction, which is usually good enough to get the overlay in the right general area.

But when it works, it works. I’ll show some examples of successful detections in the self-sourced section below.

The Memory Leak Discovery (Fun Debugging Tangent)

While testing the YOLOX integration, I was also trying to get RevenueCat working for subscriptions. Had the app running for about 20 minutes while I debugged the in-app purchase flow. Noticed it was getting sluggish, opened Instruments, and… yikes.

Base memory for the app is around 200MB. After 20 minutes of continuous use, it had climbed to 450MB. Classic memory leak pattern.

The culprit was AI induced, and AI resolved: it was creating a new CVPixelBuffer and MLMultiArray for every single frame. At 15fps, that’s 900 allocations per minute that weren’t getting cleaned up fast enough.

The fix was straightforward – use a CVPixelBufferPool for the resize buffers and pre-allocate a single MLMultiArray that gets reused. Memory now stays flat even after hours of use.

(The RevenueCat thing? I ended up ditching it entirely and going with native StoreKit2. RevenueCat is great, but keeping debug and release builds separate was more hassle than it was worth for a side project. StoreKit2 is actually pretty nice these days if you don’t need the analytics. I’m at ~80 downloads, and not a single purchase. First paid app still needs some fine tuning, clearly, on the whole freemium thing.)

Round 2: Retraining with Negative Samples

After discovering the false positive issue, I went back and retrained. This time I made sure to include images without aircraft – random sky photos, clouds, trees, buildings, just random COCO2017 stuff. The model needs to learn what’s NOT an airplane just as much as what IS one.

Here’s the extraction script that handles the negative sampling. The key insight: you need to explicitly tell the model what empty sky looks like:

def extract_airplane_dataset(split="train", negative_ratio=0.2, seed=42):
    """Extract airplane images from COCO, with negative samples."""
    with open(f"instances_{split}2017.json") as f:
        coco_data = json.load(f)

    # Find all images WITH airplanes
    airplane_image_ids = set()
    for ann in coco_data['annotations']:
        if ann['category_id'] == AIRPLANE_CATEGORY_ID:  # 5 in COCO
            airplane_image_ids.add(ann['image_id'])

    # Find images WITHOUT airplanes for negative sampling
    all_ids = {img['id'] for img in coco_data['images']}
    negative_ids = all_ids - airplane_image_ids

    # Add 20% negative images (no airplanes = teach model what ISN'T a plane)
    num_negatives = int(len(airplane_image_ids) * negative_ratio)
    sampled_negatives = random.sample(list(negative_ids), num_negatives)
    # ... copy images and annotations to output directory

I also switched from nanoish to the small model. The accuracy improvement on distant aircraft was worth the extra compute, and with INT8 quantization the inference time came in at around 5.6ms on an iPhone – way better than my napkin math predicted. Apple’s Neural Engine is impressive.

The final production model: YOLOX-Small, 640×640 input, INT8 quantized, ~8.7MB on disk. It runs at 15fps with plenty of headroom for the rest of the app on my iPhone 17 Pro.

Round 3: Self-Sourced Images and Closing the Loop

So the model works, but it was trained entirely on COCO2017 – airport tarmac photos, stock images, that kind of thing. My app is pointing at the sky from the ground. Those are very different domains.

I added a debug flag to SkySpottr for my phone that saves every camera frame where the model fires a detection. Just flip it on, walk around outside for a while, and the app quietly collects real-world training data. Over a few weeks of casual use, I accumulated about 2,000 images from my phone.

The problem: these images don’t have ground truth labels. I’m not going to sit there and manually draw bounding boxes on 2,000 sky photos. So I used YOLO26-X (Ultralytics’ latest and greatest, which I’m fine using as an offline tool since it never ships in the app) as a teacher model. Run it on all the collected images, take its high-confidence detections as pseudo-labels, convert to COCO annotation format, and now I have a self-sourced dataset to mix in with the original COCO training data.

Here’s the pseudo-labeling pipeline. First, run the teacher model on all collected images:

from ultralytics import YOLO

model = YOLO("yolo26x.pt")  # Big model, accuracy over speed

for img_path in tqdm(image_paths, desc="Processing images"):
    results = model(str(img_path), conf=0.5, verbose=False)
    boxes = results[0].boxes
    airplane_boxes = boxes[boxes.cls == AIRPLANE_CLASS_ID]

    for box in airplane_boxes:
        xyxy = box.xyxy[0].cpu().numpy().tolist()
        x1, y1, x2, y2 = xyxy
        detections.append({
            "bbox_xywh": [x1, y1, x2 - x1, y2 - y1],  # COCO format
            "confidence": float(box.conf[0]),
        })

Then convert those detections to COCO annotation format so YOLOX can train on them:

def convert_to_coco(detections):
    """Convert YOLO26 detections to COCO training format."""
    coco_data = {
        "images": [], "annotations": [],
        "categories": [{"id": 1, "name": "airplane", "supercategory": "vehicle"}],
    }

    for uuid, data in detections.items():
        img_path = Path(data["image_path"])
        width, height = Image.open(img_path).size

        if width > 1024 or height > 1024:  # Skip oversized images
            continue

        coco_data["images"].append({"id": image_id, "file_name": f"{uuid}.jpg",
                                     "width": width, "height": height})

        for det in data["detections"]:
            coco_data["annotations"].append({
                "id": ann_id, "image_id": image_id, "category_id": 1,
                "bbox": det["bbox_xywh"], "area": det["bbox_xywh"][2] * det["bbox_xywh"][3],
                "iscrowd": 0,
            })

    with open("instances_train.json", "w") as f:
        json.dump(coco_data, f)

Finally, combine both datasets in the training config using YOLOX’s ConcatDataset:

def get_dataset(self, cache=False, cache_type="ram"):
    from yolox.data import COCODataset, TrainTransform
    from yolox.data.datasets import ConcatDataset

    preproc = TrainTransform(max_labels=50, flip_prob=0.5, hsv_prob=1.0)

    # Original COCO aircraft dataset
    coco_dataset = COCODataset(data_dir=self.data_dir, json_file=self.train_ann,
                                img_size=self.input_size, preproc=preproc, cache=cache)

    # Self-sourced dataset (YOLO26-X validated)
    self_sourced = COCODataset(data_dir=self.self_sourced_dir, json_file=self.self_sourced_ann,
                                name="train", img_size=self.input_size, preproc=preproc, cache=cache)

    print(f"COCO aircraft images: {len(coco_dataset)}")
    print(f"Self-sourced images: {len(self_sourced)}")
    return ConcatDataset([coco_dataset, self_sourced])

Out of 2,000 images, YOLO26-X found aircraft in about 108 of them at a 0.5 confidence threshold – a 1.8% hit rate, which makes sense since most frames are just empty sky between detections. I filtered out anything over 1024px and ended up with a nice supplementary dataset of aircraft-from-the-ground images.

The 3-way comparison images I showed earlier came from this pipeline. Here’s what successful detections look like – the COCO-only model (red), self-sourced model (green), and YOLO26-X (right panel, shown at full resolution so you can see what we’re actually detecting):

Aircraft detection comparison - all models detecting a plane in clear sky

That’s maybe 30 pixels of airplane against blue sky, detected with 0.88 and 0.92 confidence by the two YOLOX variants.

And here’s one I particularly like – aircraft spotted through pine tree branches. Real-world conditions, not a clean test image. Both YOLOX models nail it, YOLO26-X misses at this confidence threshold:

Aircraft detection through pine tree branches

And a recent one from February 12, 2026 – a pair of what appear to be F/A-18s over Denver at 4:22 PM MST, captured at 12x zoom. The model picks up both jets at 73-75% confidence, plus the bird in the bottom-right at 77% (a false positive the app filters out via ADS-B matching). Not bad for specks against an overcast sky.

F/A-18 pair detected over Denver, CO - Feb 12, 2026

I also trained a full YOLOX-Large model (depth 1.0, width 1.0, 1024×1024 input) on the combined dataset, just to see how far I could push it. Too heavy for phone deployment, but useful for understanding the accuracy ceiling.

Conclusion

Was this worth it to avoid Ultralytics’ licensing? Since it took an afternoon and a couple evenings of vibe-coding, yes, it was not hard to switch. Not just because MIT is cleaner than AGPL, but because I learned a ton about how these models actually work. The Ultralytics ecosystem is so polished that it’s easy to treat it as a black box. Building from YOLOX forced me to understand some of the nuances, the training configs, and the tradeoffs between model size and accuracy.

Plus, I can now say I trained my own object detection model from scratch. That’s worth something at parties. Nerdy parties, anyway.

SkySpottr is live on the App Store if you want to see the model in action – point your phone at the sky and watch it lock onto aircraft in real-time.

The self-sourced pipeline is still running. Every time I use the app with the debug flag on, it collects more training data. The plan is to periodically retrain as the dataset grows – especially now that I’m getting images from different weather conditions, times of day, and altitudes. The COCO-only model was a solid start, but a model trained on actual ground-looking-up images of aircraft at altitude? That’s the endgame.

Categories
ADS-B SDR Tutorials

Getting Started With SDR (software defined radio): Tutorial

Introduction – what is SDR?

SDR stands for software defined radio. It is a term used to describe devices that can receive radio frequency signals over a wide range of frequencies. These devices most commonly interface with USB drives of computers / Raspberry Pi / similar. There are standalone-devices but they often cost quite a bit more than the USB devices. The best part about the base-level SDRs? They’re less than $50. I have a couple that I paid $18 for.

Video link to follow along – https://youtu.be/HUkaIJhoYUs

So you’re here on the “Getting Started with SDR” post. In this SDR tutorial post we’ll get a SDR plugged into your Windows computer, install drivers, and start sniffing for signals. We’ll try FM radio first since they have very powerful transmitters and there is almost certainly one you can receive at your hose, followed by checking for those wireless temperature sensors, and then some aircraft position signals.

What does a SDR look like? Below is a image with 3 I have around the house that I snagged for a picture (USB plug for size reference). Below the picture is the table of contents for the post.

picture showing 3 SDR devices - NooElec NESDR Nano 2, FlightAware ProStick, RTL-SDR Blog R820T2 RTL2832U V#
Example of a couple SDRs (all Realtek chipset-based, hence the RTL abbreviation)

SDR Tutorial Contents

  1. Purchasing a suitable SDR
  2. Plugging it into a Windows computer and installing drivers
  3. Installing SDR# (a program to monitor any and all frequencies)
  4. Listening to a FM radio station
  5. Installing rtl_433 and listening for wireless temperature sensors
  6. Installing dump1090 and listening for aircraft position signals

1 – Purchasing a suitable SDR

To get started, you’ll obviously need a RTL-SDR. I recommend the NooElec devices, specifically the NooElec NESDR Smart v5 bundle. NooElec devices have lasted the longest out of the 8 SDRs I’ve purchased and have been very useful for me. It includes 3 different antenna (433 MHz for sensors, 1090 MHz for ADS-B aircraft signals, and an adjustable antenna) which are useful for the rest of the tutorial:

With a SDR in hand, let’s get started!

2 – Installing drivers and plugging in the RTL-SDR

I use Windows 10 for this SDR tutorial, but I use Linux for the long-term monitoring I have in place around the house. Linux doesn’t ever automatically reboot for updates, and is generally much more stable (and doesn’t need a license). Windows is easy to get started so we’ll use it.

The instructions for installation are a bit long so I’ll just leave a link to the official source where they will always have the most up to date drivers and such – https://www.rtl-sdr.com/rtl-sdr-quick-start-guide/. I will re-write these at some point (and also make a video) but I’ll just leave the link for now.

3 – SDRSharp installation

If you followed the instructions in the link above, you should have a file called SDRSharp.exe in the folder you used:

SDRSharp.exe present in working directory

This means it is already “installed” since it is just a simple executable file. No need to click next, next, next finish to install.

4 – Listening to a FM radio station with your RTL-SDR

Go ahead and double-click on SDRSharp.exe and launch it! First thing you need to do is select the RTL-SDR USB option from the sources drop down menu:

Selecting the RTL-SDR USB source in SDRSharp

Next up, we need to crank the gain to get useful signal out of the SDR. Click the gear icon, make sure the RTL2832U device is selected, then set the RF gain slider to around 40dB, then click close:

Setting the RTL-SDR gain to 40.2 dB

Now we are ready to listen to FM radio!

Ensure the WFM radio button is selected in the signal type, set the zoom slider all the way down, set the step to 100 kHz, then click the frequency numbers up/down to pick a known FM station. This should get everything ready to go:

Setting SDRSharp for FM radio station reception

With all that setup out of the way, click the play button and watch the signals start streaming in! Assuming your speakers are set to a decent volume, you’ll hear the radio from your computer! From my 2nd story bedroom near Broomfield, CO, I can easily get 98.5 MHz and 99.5 MHz (and many others). You can also check the “FM Stereo” checkbox on the left side if you know you’ll be receiving FM stereo. Make sure you uncheck it if you start looking at other things:

SDRSharp FM radio reception for 98.5 Mhz (KYGO) and 99.5 MHz (KQMT)

Here is a picture showing my setup for writing this blog – a simple NooElec Nano SDR I bought in 2016, a little bit of cable, and a 1090 MHz antenna (which is clearly not ideal for FM radio frequencies, but radio is so powerful it doesn’t really matter). Also say hi to Fluffy the cat:

RTL-SDR set up for writing this blog post, with an appearance from Fluffy the black cat

5 – Installing rtl_433 and listening for wireless temperature/humidity sensors

Ok so now that we know radio works, let’s see what other radio frequency signals are traveling through the air. We will start by downloading the rtl_433 Windows release from GitHub. The latest version as of this post is here (https://github.com/winterrace/rtl_433_win/releases/download/v19.8.19/rtl_433_win_2019-08-19.zip). Unzip the .zip file. There should be two files inside, rtl_433.exe and rtlsdr.dll. I put these files on my desktop in a folder called rtl_433:

rtl_433.exe in a folder called rtl_433 on my desktop

Now open a command window, and change directory (cd) to Desktop/rtl_433.

C:\Users\Austin>cd Desktop\rtl_433

C:\Users\Austin\Desktop\rtl_433>

Ok now we’re in the rtl_433 directory so we can run commands now. The most basic command for this program is to run it and only specify the gain. We used 40 dB for the FM radio so let’s use 40 again by specifying it with the -g option (you can view all commands by running rtl_433 -h):

rtl_433 -g 40

The output will show the following (ending in “Tuned to 433.92 MHz”) if all went well:

Let it run for a couple minutes. You might see some thermometers and other such devices! Here is what my output looks like after about 60 seconds:

If you look closely, you can see there are three separate devices broadcasting on 433 MHz that I can pick up with my (still not desirable) 1090 MHz antenna! One is sitting in my garage, another is next to me in the bedroom, and I don’t actually know where the 3rd is or who owns it.

With the same, tiny USB RTL-SDR, we have picked up FM radio as well as temperature/humidity readings from three separate sensors!

6 – Installing dump1090 and listening for aircraft position (ADS-B) signals

Last up for this RTL-SDR tutorial is installing dump1090 and checking to see if we can pick up any aircraft signals.

Dump1090 is a utility written many years ago that decodes aircraft ADS-B position signals. Some brave souls ported it for use in Windows a while ago. Using the directions and links from https://sonicgoose.com/using-dump1090-in-windows/ I have distilled it down for you. Download the dump1090 windows package that I’ve rehosted here –

And then extract it to your desktop in a folder called dump1090.

There is a .bat file (batch file) in the folder, double-click it to run it:

dump1090 folder in Windows showing dump1090.bat highlighted

You will be presented with a screen that updates in realtime as signals come in. You are now receiving ADS-B signals with the same device that could listen to FM radio, and also temperature/humidity sensors around the house!

Windows dump1090 showing ADS-B data streaming in from the RTL-SDR

All this from a 1090 MHz antenna (which is actually finally the right frequency for the application at hand). Fluffy is still supervising:

1090 MHz antenna attached to RTL-SDR to pick up aircraft ADS-B signals. Cat is helping (and has not moved in 3 hours).

Conclusion

In this SDR tutorial, we have purchased a RTL-SDR, installed the drivers, plugged it in, listened to FM radio, checked for wireless temperature/humidity sensors and found 3, and listened for aircraft ADS-B signals (and found 15 aircraft broadcasting in the last screenshot). This is an addictive hobby. With the right antenna, you can hear people speaking and morse code coding from across the world. In my next post I’ll show how to implement some of these into automated programs to take the data and input it into Home Assistant and other databases.

One last thing – receiving RF signals is 98% about the antenna and 2% about what you’re receiving the signals with! The base antennas that come with RTL-SDRs are good for the basics but if you really want to get into receiving interesting/distant signals, be prepared to spend 2-3x the cost of a SDR on a single antenna.

Hope you learned something and enjoyed this tutorial!

Categories
ADS-B SDR

Receiving aircraft ADS-B (position) signals – part 4 (antenna up on roof)

Antenna up on roof

Coming from part 3, where I wanted to move the antenna, I finally got the antenna up on the roof. Our chimney was decommissioned by the previous owners and as far as I can tell, there isn’t brick under the siding (also why does our chimney have siding on it). So it is sitting a little lower than it should be but it is basically at the highest position of the roof. This has dramatically increased the ability to receive aircraft ADS-B signals.

Results

The results are pretty amazing. We’ve had bad weather for a week now but it’s going to be a clear day today. As of 9:12AM, my Raspberry Pi PiAware ADS-B signal receiver sees 116 aircraft, of which 103 are reporting positions. It is receiving 607 messages per second. The map looks like this:

116 aircraft signals received, 103 with position. farthest out is 190 NM.

You can see aircraft lining up to arrive into KDEN spaced out at regular intervals. It’s also picking up 3 planes on the ground at KBJC which is the closest airport to the antenna.

FlightAware has a cool radar type map that shows positions by compass direction and distance. The numbers speak for themselves.

Before

Max distance reported is in the 100-150 nm bucket (327 total reports)

After

That same 100-150 nm bucket now has 24k reports

Interesting features

While typing this post, the position count increased to 118. There are some interesting features I’m seeing – a survey plane over the Breckenridge area, a lot of planes on the ground at KBJC (not line of sight to my antenna), and even plane on the ground at KDEN (KDEN is definitely not line of sight to my antenna).

Survey grid being flown by N94S

Planes on the ground at KBJC

I see 4 Cessna/trainer type planes waiting for takeoff for 30R at KBJC. I’m even picking up a corporte jet type aircraft on the ground by the hangars (N4840W). None of this is line of sight to my antenna. There’s a chance the ADS-B signals are bouncing off buildings or something. I shouldn’t be seeing these.

The elevation profile to 30R at KBJC. Antenna on left, run up area on the right. Barely not LOS (line of sight).

Plane on the ground at KDEN

United UAL364 / N802UA (an Airbus A319) on the ground on runway 16L/34R heading south
Elevation profile to south end of 16L/34R at KDEN. Antenna on left, 16L/34R on right. Definitely not LOS. No idea how I’m picking up these signals. I see a plane on the ground at KDEN multiple times a day since moving the antenna.

151 planes!

I started this post around 9am on 4/18. Just before noon, there were 151 planes being tracked by my PiAware station! 773 messages per second. Notice that plane way out there over west central Nebraska – that’s probably 210 NM out!

Conclusion

Moving my FlightAware ADS-B antenna to the roof drastically increased the range and messages received. As a reminder from when I detailed the equipment in Post 2 – the antenna feeds a 1090MHz ADS-B filter, which in turn feeds the FlightAware Pro Stick. I don’t think I’ll make any other changes to the system other than put it on a battery with solar charger.

Categories
ADS-B

Receiving aircraft ADS-B (position) signals – part 3 (antenna reposition)

Welcome back from Receiving aircraft ADS-B (position) signals – part 2!

Yesterday I moved the antenna up a couple feet from a “very temporary” position to a “less temporary” position. I still need to get it up on the roof. Either way, my reception and max range have increased by at least 20%. It is still in my garage, which is a terrible location, but at least is elevated.

New antenna placement

ADS-B antenna in garage
ADS-B antenna in garage (we need a shed for all that gardening stuff)

The antenna needs to be vertical for maximum reception and upside down works just as well as right side up!

New antenna placement results

I saw up to 82 aircraft being tracked this morning. You can see the big planes lining up for southerly landings at KDEN on three separate runways and a few planes taking off to the southeast.

piaware updated antenna KDEN focus
PiAware updated antenna KDEN focus

Here is a screenshot zoomed out. I’ve got quite a few position reports from >100 nm out.

piaware ADS-B zoomed out KDENProposed final placement of the FlightAware ADS-B antenna

Proposed placement of ADS-B antenna on chimney
Proposed placement of ADS-B antenna on chimney (1 is where it currently is inside the garage and 2 is desired final placement)

I have a new weather station coming this weekend so I’ll try to combine roof trips to install the weather station and the 1090 MHz Flightaware antenna at the same time. I’ll also need to drill a hole in the side of the house to run the cable. Or maybe I do it all solar powered and use WiFi ?? I also have a 25W solar panel arriving this weekend. Keeping it physically isolated from the house would eliminate some lightning risk. Decisions, decisions. Be on the lookout for a part 4 with the results from the new roof placement! Part 4 – moving the antenna to the roof, has produced great results! I’m now seeing 150+ planes at once and getting over 700 messages per second.

Categories
ADS-B SDR

Receiving aircraft ADS-B (position) signals – part 2

Welcome back from part one (Receiving aircraft ADS-B (position) signals)! Now that you have all the required equipment – what do you need to do to set it up? Thankfully, the folks over at FlightAware have made this super easy. FlightAware provides a flight tracking platform that is mostly fed by users like me (and soon to be you!). In return for feeding them data, they will give you a free enterprise subscription, which is normally $89/month. It adds a lot of tracking abilities which are great for aviation nerds like myself. To get the most data possible, they have put together some great getting started guides, which I will link here – https://flightaware.com/adsb/piaware/build. The short version is:

  1. Write the Piaware operating system to your SD card
  2. Either enable WiFi or plug into your router
  3. Plug everything in
  4. Claim your station on FlightAware.com after a few minutes
  5. Watch the data start flowing!

Here is a picture of the most basic setup possible:

Simple ADS-B receiver setup with RTL-SDR and 1090 MHz antenna
Simple ADS-B receiver setup with RTL-SDR and 1090 MHz antenna

To really increase your reception, there are three things you need to do (but before you proceed, I must warn you – this becomes addictive):

  1. Get a bigger/better antenna. Antennas are measured by something called “gain”. The more gain, the better (generally speaking). More gain means the same signal is received stronger and with more clarity.
  2. Reduce the other noise. A bigger antenna will amplify all signals in the same frequency range. ADS-B is on a very specific frequency (1090 MHz). An ADS-B filter reduces the signal at frequencies other than 1090 MHz.
  3. Amplify the filtered signal. With the other signals filtered out, amplify what remains (legit 1090 MHz ADS-B signals).

This is what my full setup looks like:

Full ADS-B setup with 1090 MHz antenna, 1090 MHz filter, and Flightaware pro stick
Full ADS-B setup with 1090 MHz antenna, 1090 MHz filter, and Flightaware Pro stick

FlightAware started producing each of these a couple years ago (again, sticking with the theme of making it easy to provide them data). Originally, each was a separate item. Now the amplifier and filter are built into the same device on the FlightAware Pro Stick Plus. The antenna will remain separate. These upgrades together will cost around $80-90. I’ve provided some Amazon links below to check the current prices:

I like to keep the filter and receiver separate so if something goes wrong with either I can keep sending signals. As a side note, I am up to 735 days feeding FlightAware without interruption (two years and two days)!

flightaware connected for 735 days straight
flightaware connected for 735 days straight

The antenna is currently hanging in my garage which isn’t ideal but I still get signals from 100+ miles away consistently. I messed with a bunch of DIY antennas that I’ll post one day but settled on the FlightAware stuff because it works so well. I have the full setup of FlightAware antenna feeding the 1090 MHz SMA filter into the Pro Stick. When I lived in California this yielded 100-200 planes on busy days up to 200 miles away. This stuff is good fun, and as I warned above, it gets addictive. There is a physical limit though to how far you can receive signals, and that limit is around 250 miles for planes at 40,000 ft due to the curvature of the earth. Planes flying lower will fall off at closer distances.

Repositioning the antenna

I moved the antenna up a bit and am getting 20% more messages per second and distance – take a look here at Receiving aircraft ADS-B (position) signals – part 3 (antenna reposition)

Please let me know in the comments what you want to see about my setup! I will get around to making YouTube videos eventually to post because I know a lot of people like videos more than text but I want to do the text stuff first to get my thoughts together.

Austin’s Nerdy Things is a participant in the Amazon Services LLC Associates Program, an affiliate advertising program designed to provide a means for sites to earn advertising fees by advertising and linking to amazon.com.