# Single Object Detection: Training dan Serving dengan FastAPI

## Daftar Isi

*   [Recap Singkat](#recap-singkat)
    
*   [Menyiapkan Dataset & Model](#menyiapkan-dataset--model)
    
*   [IoU: Metrik untuk Mengukur Kualitas Prediksi](#iou-metrik-untuk-mengukur-kualitas-prediksi)
    
*   [Training Loop Lengkap](#training-loop-lengkap)
    
*   [Hasil Training Pertama](#hasil-training-pertama)
    
*   [Serving Model dengan FastAPI](#serving-model-dengan-fastapi)
    
*   [Cheat Sheet](#cheat-sheet)
    
*   [Lanjut ke Part 3](#lanjut-ke-part-3)
    

> 📚 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](https://github.com/arielshakaramiro/single-object-detection-car) 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.

```python
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

```python
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.

```python
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:

```python
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

```python
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:

```python
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](https://drive.google.com/drive/folders/13qoW2HCHWUVfzr4EFKUc3ZngoQT_D97A?usp=sharing) 💻 Repo: [GitHub](https://github.com/arielshakaramiro/single-object-detection-car)

* * *

*Bagian dari series Computer Vision Super Class, Sesi 3: Single Object Detection.*
