3. Train the classifier#
The second stage looks closely at each detection and decides what it
is: MILCO (mine-like contact), NOMBO (non-mine-like bottom object)
or clutter (not an object). It sees a 64 px chip, three times the
box’s longer side, centred on the box: detail a detector working on
whole images does not use.
The MILCO and NOMBO chips come from the labelled boxes of the
training years. The clutter chips are the detector’s own false alarms
on those years (“hard negatives”): what the classifier must reject is
what the detector gets wrong. A first version used random patches of
seabed as clutter and removed only 3 of the 57 false alarms on the test
year.
The model is a ResNet-18 from torchvision, pretrained on ImageNet and fine-tuned for 30 epochs, with the loss weighting each class by its inverse frequency. With your own survey, build_target_dataset renders target chips and background images straight from the sonar data.
Result#
train {'MILCO': 360, 'NOMBO': 229, 'clutter': 1288}
val {'MILCO': 49, 'NOMBO': 0, 'clutter': 55}
best validation accuracy 0.55
The validation year has no NOMBO, so step 4 is the
better measure.
"""
Step 3: train the second-stage chip classifier (torchvision ResNet-18).
MILCO / NOMBO / clutter, with the detector's own false alarms as clutter (hard
negatives). Run after step 2. A few minutes on a GPU.
Usage: python examples/atr/atr_3_train_classifier.py ATR_DIR
See "Target recognition" in the sidescanpy docs for what each step does and its results.
"""
# 2026 rshomberg@uri.edu
import random
import sys
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from torch import nn
from torchvision import models, transforms
from ultralytics import YOLO
root = Path(sys.argv[1] if len(sys.argv) > 1 else "atr_santos")
CLASSES = ["MILCO", "NOMBO", "clutter"]
CHIP_PX = 64
TRAIN_YEARS, VAL_YEARS = ["2010", "2015", "2018"], ["2021"]
rng = random.Random(0)
torch.manual_seed(0)
def read_boxes(txt: Path) -> list[tuple[int, float, float, float, float]]:
"""YOLO label file -> (class, x_centre, y_centre, width, height), relative units."""
t = txt.read_text().split()
return [(int(t[i]), *map(float, t[i + 1:i + 5])) for i in range(0, len(t), 5)]
def chip(image: Image.Image, xc: float, yc: float, w: float, h: float) -> Image.Image:
"""Square crop, three times the box's longer side (at least 32 px), centred on it."""
W, H = image.size
side = max(32.0, 3 * max(w * W, h * H))
x0, y0 = xc * W - side / 2, yc * H - side / 2
return image.crop((round(x0), round(y0), round(x0 + side), round(y0 + side))).resize((CHIP_PX, CHIP_PX))
def iou(a, b):
"""Boxes as (xc, yc, w, h), relative units."""
ax0, ay0, ax1, ay1 = a[0] - a[2] / 2, a[1] - a[3] / 2, a[0] + a[2] / 2, a[1] + a[3] / 2
bx0, by0, bx1, by1 = b[0] - b[2] / 2, b[1] - b[3] / 2, b[0] + b[2] / 2, b[1] + b[3] / 2
inter = max(0, min(ax1, bx1) - max(ax0, bx0)) * max(0, min(ay1, by1) - max(ay0, by0))
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
# %%
# Chips. Each labelled box gives one chip of its class. The detector runs
# on every image at the low threshold used later; each detection that
# overlaps no label gives a clutter chip.
detector = YOLO(root / "runs" / "detector" / "weights" / "best.pt")
def make_chips(years):
chips, labels = [], []
for year in years:
for jpg in sorted((root / "images" / year).glob("*.jpg")):
image = Image.open(jpg).convert("RGB")
boxes = read_boxes(jpg.with_suffix(".txt"))
for c, xc, yc, w, h in boxes:
chips.append(chip(image, xc, yc, w, h))
labels.append(c)
result = detector.predict(jpg, conf=0.05, imgsz=640, verbose=False)[0]
for det in result.boxes.xywhn.tolist():
if all(iou(det, b[1:]) < 0.1 for b in boxes):
chips.append(chip(image, *det))
labels.append(2)
return chips, labels
train_chips, train_labels = make_chips(TRAIN_YEARS)
val_chips, val_labels = make_chips(VAL_YEARS)
for name, labels in [("train", train_labels), ("val", val_labels)]:
print(name, {c: labels.count(k) for k, c in enumerate(CLASSES)})
# %%
# Fine-tune. Classes are unbalanced (NOMBO is rare), so the loss weights
# each class by its inverse frequency.
# Augmentation flips left-right only and jitters brightness, as for the
# detector.
normalize = transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
train_tf = transforms.Compose([transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.3, 0.3),
transforms.ToTensor(), normalize])
eval_tf = transforms.Compose([transforms.ToTensor(), normalize])
device = "cuda" if torch.cuda.is_available() else "cpu"
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, len(CLASSES))
model = model.to(device)
counts = np.bincount(train_labels, minlength=len(CLASSES))
loss_fn = nn.CrossEntropyLoss(weight=torch.tensor(counts.sum() / (len(CLASSES) * counts), dtype=torch.float32).to(device))
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
def evaluate(chips, labels):
model.eval()
with torch.no_grad():
x = torch.stack([eval_tf(c) for c in chips]).to(device)
pred = model(x).argmax(1).cpu().numpy()
return pred, float((pred == np.array(labels)).mean())
best_acc, n = 0.0, len(train_chips)
for _ in range(30): # epochs
model.train()
order = list(range(n))
rng.shuffle(order)
for i in range(0, n, 64):
idx = order[i:i + 64]
x = torch.stack([train_tf(train_chips[j]) for j in idx]).to(device)
y = torch.tensor([train_labels[j] for j in idx]).to(device)
optimizer.zero_grad()
loss_fn(model(x), y).backward()
optimizer.step()
_, acc = evaluate(val_chips, val_labels)
if acc >= best_acc:
best_acc = acc
torch.save(model.state_dict(), root / "classifier.pt")
print(f"best validation accuracy {best_acc:.2f}")