Skip to main content

Command Palette

Search for a command to run...

Single Object Detection: Training dan Serving dengan FastAPI

Updated
โ€ข7 min readโ€ขView as Markdown
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.

AI Engineering Study Notes

Part 47 of 50

A personal collection of AI engineering study notes โ€” covering computer vision, deep learning, and model deployment โ€” built from AI Super Class coursework and independent exploration.

Up next

Single Object Detection: Training and Serving with FastAPI

Table of Contents Quick Recap Setting Up the Dataset and Model IoU: Measuring How Good a Prediction Is The Full Training Loop First Training Results Serving the Model with FastAPI Cheat Sheet

More from this blog

S

Shaka's AI Journal

62 posts

A personal AI engineering journal โ€” documenting hands-on learning in computer vision, deep learning, data pipelines, and model deployment. Study notes, working code, and honest write-ups from coursework and independent projects, published in Indonesian and English.