Single Object Detection: Training dan Serving dengan FastAPI

Daftar Isi
๐ Series Computer Vision โ Sesi 3. Part 2 dari 3. Belum baca teorinya? Part 1 membahas konsep dan arsitektur dulu sebelum masuk ke praktik di sini.
Recap Singkat
Part 1 sudah membahas gambaran besarnya: gambar masuk lewat backbone CNN, diringkas jadi beberapa angka output (is_object plus koordinat bounding box), dilatih pakai kombinasi tiga loss function.
Sekarang waktunya membangun modelnya dari nol pakai PyTorch dan MobileNetV2 sebagai backbone, sampai deploy jadi API pakai FastAPI. Kode di bawah adalah versi pertama yang benar-benar dilatih dan diukur hasilnya, bukan sekadar teori.
๐ Kode lengkap dan hasil selanjutnya juga tersedia di GitHub repo kalau kamu mau langsung eksperimen sendiri.
Menyiapkan Dataset & Model
Dataset Class
Dataset membaca gambar plus file anotasi .txt yang isinya koordinat x y w h mentah. Kalau file anotasi tidak ada, gambar itu dianggap tidak punya objek.
class ObjectDetectionDataset(Dataset):
def __init__(self, image_folder, transform=None):
self.image_folder = image_folder
self.image_files = [f for f in os.listdir(image_folder) if f.endswith(('png', 'jpg', 'jpeg'))]
self.transform = transform
def __len__(self):
return len(self.image_files)
def __getitem__(self, idx):
img_name = self.image_files[idx]
img_path = os.path.join(self.image_folder, img_name)
annotation_path = os.path.join(self.image_folder, img_name.replace('jpg', 'txt'))
image = Image.open(img_path).convert("RGB")
img_width, img_height = image.size
is_object = torch.tensor([0], dtype=torch.float32)
bbox = torch.tensor([0, 0, 0, 0], dtype=torch.float32)
if os.path.exists(annotation_path):
with open(annotation_path, 'r') as f:
lines = f.readlines()
if len(lines) > 0:
x, y, w, h = map(float, lines[0].strip().split())
x /= img_width; y /= img_height; w /= img_width; h /= img_height
is_object = torch.tensor([1], dtype=torch.float32)
bbox = torch.tensor([x, y, w, h], dtype=torch.float32)
if self.transform:
image = self.transform(image)
return image, is_object, bbox
Poin penting: koordinat bounding box dinormalisasi ke rentang 0 sampai 1, bukan piksel mentah. Ini membuat skala loss tetap konsisten walau ukuran gambar berbeda-beda.
Model: MobileNetV2 dengan Head Kustom
class ObjectDetectionModel(nn.Module):
def __init__(self):
super(ObjectDetectionModel, self).__init__()
self.backbone = mobilenet_v2(weights="DEFAULT").features
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(1280, 512),
nn.ReLU(),
nn.Linear(512, 5) # 1 is_object + 4 bbox
)
def forward(self, x):
x = self.backbone(x)
x = self.classifier(x)
is_object = torch.sigmoid(x[:, :1])
bbox = torch.sigmoid(x[:, 1:])
return is_object, bbox
Sedikit catatan kecil: source material aslinya masih pakai mobilenet_v2(pretrained=True), yang sudah deprecated di torchvision versi baru. Ganti ke weights="DEFAULT" supaya tetap kompatibel dengan environment terkini. Hal-hal kecil seperti ini sering ditemui begitu kode lama dicoba jalankan lagi.
AdaptiveAvgPool2d((1, 1)) di sini meratakan seluruh feature map jadi satu vektor sebelum masuk ke fully connected layer. Sederhana dan cukup untuk versi pertama ini, tapi ada trade-off yang baru kelihatan dampaknya belakangan, dibahas di Part 3.
IoU: Metrik untuk Mengukur Kualitas Prediksi
IoU (Intersection over Union) dipakai untuk mengukur seberapa "nyambung" bounding box prediksi dengan ground truth. Bukan untuk training, itu tugas loss function, tapi untuk evaluasi seberapa bagus hasilnya.
def calculate_iou(box1, box2):
x1, y1, w1, h1 = box1
x2, y2, w2, h2 = box2
x_left = max(x1 - w1/2, x2 - w2/2)
y_top = max(y1 - h1/2, y2 - h2/2)
x_right = min(x1 + w1/2, x2 + w2/2)
y_bottom = min(y1 + h1/2, y2 + h2/2)
if x_right < x_left or y_bottom < y_top:
return 0.0
intersection = (x_right - x_left) * (y_bottom - y_top)
union = w1*h1 + w2*h2 - intersection
return intersection / union
Loss gabungan (MixedLoss) menyatukan Binary Cross Entropy untuk objectness dengan MSE untuk lokasi, dengan bobot yang bisa diatur:
class MixedLoss(nn.Module):
def __init__(self, weight_objectness=1.0, weight_localization=1.0):
super(MixedLoss, self).__init__()
self.bce_loss = nn.BCELoss()
self.mse_loss = nn.MSELoss()
self.weight_o = weight_objectness
self.weight_l = weight_localization
def forward(self, pred_object, true_object, pred_bbox, true_bbox):
loss_object = self.bce_loss(pred_object, true_object)
loss_bbox = self.mse_loss(pred_bbox, true_bbox)
return self.weight_o * loss_object + self.weight_l * loss_bbox
๐ค Coba Tebak Dulu: kalau IoU cuma dipakai untuk evaluasi, bukan untuk training, kenapa nggak dijadikan loss function langsung saja? Klik untuk lihat jawaban.
Pertanyaan bagus, dan jawabannya jadi salah satu topik utama di Part 3. Versi pertama ini memang belum memakainya sebagai loss, hanya sebagai metrik pemantau di luar training loop.
Training Loop Lengkap
def train_model(model, train_loader, test_loader, criterion, optimizer, device, num_epochs=10):
model.train()
best_iou = 0.0
for epoch in range(num_epochs):
running_loss, total_iou_train, total_iou_test = 0.0, 0.0, 0.0
for images, is_object, bboxes in train_loader:
images, is_object, bboxes = images.to(device), is_object.to(device), bboxes.to(device)
optimizer.zero_grad()
pred_object, pred_bbox = model(images)
loss = criterion(pred_object, is_object, pred_bbox, bboxes)
loss.backward()
optimizer.step()
running_loss += loss.item()
for i in range(len(bboxes)):
if is_object[i] > 0.5:
total_iou_train += calculate_iou(pred_bbox[i].detach().cpu().numpy(), bboxes[i].cpu().numpy())
avg_iou_train = total_iou_train / len(train_loader.dataset)
print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {running_loss/len(train_loader):.4f}, IoU (Train): {avg_iou_train:.4f}")
model.eval()
with torch.no_grad():
for images, is_object, bboxes in test_loader:
images, is_object, bboxes = images.to(device), is_object.to(device), bboxes.to(device)
pred_object, pred_bbox = model(images)
for i in range(len(bboxes)):
if is_object[i] > 0.5:
total_iou_test += calculate_iou(pred_bbox[i].cpu().numpy(), bboxes[i].cpu().numpy())
avg_iou_test = total_iou_test / len(test_loader.dataset)
print(f"IoU (Test): {avg_iou_test:.4f}")
torch.save(model.state_dict(), 'last.pt')
if avg_iou_test > best_iou:
best_iou = avg_iou_test
torch.save(model.state_dict(), 'best.pt')
model.train()
Hasil Training Pertama
Dataset berisi 241 gambar mobil, dibagi 192 untuk training dan 49 untuk testing (80:20). Training dijalankan di Google Colab dengan GPU Tesla T4, 30 epoch.
| Metrik | Nilai |
|---|---|
| Best IoU (test) | 0.7186, di epoch 27 |
| IoU (train, epoch akhir) | 0.8382 |
| Gap train/test | 0.120 |
Angka-angka ini penting dicatat: bukan estimasi, tapi hasil run yang sebenarnya. Gap 0.120 antara IoU train dan test menunjukkan sedikit overfitting, model agak lebih "hafal" data training dibanding kemampuan generalisasinya. Ini jadi titik awal yang menarik untuk dikembangkan lebih lanjut, dan itu yang dibahas panjang di Part 3.
Serving Model dengan FastAPI
Model yang sudah dilatih dibungkus jadi API sederhana:
import torch
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import JSONResponse
from torchvision import transforms
from torchvision.models import mobilenet_v2
from torch import nn
from PIL import Image
import uvicorn
import io
app = FastAPI()
model = ObjectDetectionModel()
model.load_state_dict(torch.load("best.pt", map_location=torch.device('cpu')))
model.eval()
transform = transforms.Compose([transforms.Resize((224, 224)), transforms.ToTensor()])
@app.post("/predict")
async def predict(file: UploadFile = File(...)):
try:
image = Image.open(io.BytesIO(await file.read())).convert("RGB")
image_tensor = transform(image).unsqueeze(0)
with torch.no_grad():
is_object, bbox = model(image_tensor)
is_object_value = is_object.item()
bbox = bbox.squeeze(0).tolist()
if is_object_value > 0.5:
response = {"is_object": True, "bbox": {"x": bbox[0], "y": bbox[1], "w": bbox[2], "h": bbox[3]}}
else:
response = {"is_object": False, "bbox": None}
return JSONResponse(content=response)
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
Endpoint /predict menerima file gambar lewat multipart upload, dan mengembalikan JSON berisi is_object dan bbox. Kalau kamu deploy langsung dari Colab tanpa server sendiri, ada juga variasi yang pakai pyngrok untuk tunneling plus visualisasi bounding box langsung di atas gambar. Kode lengkapnya ada di repo.
๐ง Quiz Time: klik untuk buka
1. Kenapa response API mengembalikan bbox: None saat is_object bernilai false, bukan koordinat {0,0,0,0}? Karena {0,0,0,0} bisa disalahartikan sebagai bounding box valid di titik nol. None secara eksplisit menyatakan tidak ada bounding box untuk ditampilkan, lebih aman untuk sisi client yang mengonsumsi API ini.
2. Kenapa endpoint ini pakai async def untuk fungsi predict? Karena membaca file upload (await file.read()) adalah operasi I/O yang idealnya tidak memblokir request lain masuk selagi menunggu. FastAPI mendukung ini secara native lewat async.
Cheat Sheet
[x] Dataset baca gambar plus anotasi
.txt, normalisasi bbox ke 0-1[x] Model: MobileNetV2 backbone (transfer learning) plus head kustom
[x] Loss: BCE (objectness) + MSE (localization), berbobot
[x] IoU dipakai sebagai metrik evaluasi, bukan loss
[x] Hasil v1: IoU test 0.7186, gap train/test 0.120
[x] Serving: endpoint FastAPI
/predict, terima gambar, kembalikan JSON
Lanjut ke Part 3
Model v1 di atas sudah "jalan", tapi begitu diuji pakai foto-foto dunia nyata di luar dataset training, muncul pola yang tidak terduga. Part 3 membahas dua iterasi perbaikan lebih lanjut, dan satu temuan yang mengubah cara memandang hasil evaluasi model kecil seperti ini.
๐ Dataset: Google Drive ๐ป Repo: GitHub
Bagian dari series Computer Vision Super Class, Sesi 3: Single Object Detection.






