""" Fish Quality Check — FastAPI Backend ===================================== Endpoint: POST /predict → kirim foto mata + insang, terima grade A/B/C GET /health → cek status server & model GET /docs → dokumentasi API otomatis (Swagger UI) Jalankan: uvicorn api:app --host 0.0.0.0 --port 8000 --reload """ import io import os import sys import time import logging from datetime import datetime from typing import Optional from fastapi import FastAPI, File, UploadFile, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel from PIL import Image # Pastikan utils bisa diimport dari mana pun uvicorn dijalankan BASE_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, BASE_DIR) from utils.inference import classify_fish, is_model_ready, load_model # ── Logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logger = logging.getLogger(__name__) # ── App ─────────────────────────────────────────────────────────────────────── app = FastAPI( title="Fish Quality Check API", description=""" ## API untuk grading kualitas ikan ekspor Upload foto **mata** dan **insang** ikan, dapatkan **Grade A / B / C** secara otomatis. ### Logika Grading | Mata | Insang | Grade | Keterangan | |----------|-----------|-------|-----------------| | Segar | Segar | **A** | Sangat Segar | | Segar | Tdk Segar | **B** | Cukup Segar | | Tdk Segar| Segar | **B** | Cukup Segar | | Tdk Segar| Tdk Segar | **C** | Tidak Segar | """, version="1.0.0", ) # ── CORS ───────────────────────────────────────────────────────────────────── # Tambahkan URL frontend kamu di sini ALLOWED_ORIGINS = [ "http://localhost:3000", # React dev server "http://localhost:5173", # Vite dev server "http://localhost:8080", # Vue dev server "https://your-frontend.com", # Ganti dengan domain produksi ] app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["*"], ) # ── Response schemas (Pydantic) ─────────────────────────────────────────────── class PartResult(BaseModel): kelas: str status: str confidence: float probabilitas: dict class PredictResponse(BaseModel): grade: str label: str warna: str deskripsi: str layak_ekspor: bool mata: PartResult insang: PartResult waktu_proses_ms: float timestamp: str class HealthResponse(BaseModel): status: str model_ready: bool device: str timestamp: str # ── Startup: pre-load model ─────────────────────────────────────────────────── @app.on_event("startup") async def startup_event(): logger.info("Server starting up...") if is_model_ready(): try: load_model() logger.info("Model loaded successfully on startup.") except Exception as e: logger.warning(f"Model load failed on startup: {e}") else: logger.warning("Model file not found. Run train.py first.") # ── Middleware: log setiap request ──────────────────────────────────────────── @app.middleware("http") async def log_requests(request: Request, call_next): start = time.time() response = await call_next(request) duration = (time.time() - start) * 1000 logger.info(f"{request.method} {request.url.path} → {response.status_code} ({duration:.1f}ms)") return response # ── Helper: validasi file upload ────────────────────────────────────────────── ALLOWED_TYPES = {"image/jpeg", "image/png", "image/jpg", "image/webp"} MAX_SIZE_MB = 10 def validate_image(file: UploadFile, field_name: str) -> None: if file.content_type not in ALLOWED_TYPES: raise HTTPException( status_code=400, detail=f"Format file '{field_name}' tidak didukung. Gunakan JPG atau PNG." ) async def read_image(file: UploadFile, field_name: str) -> Image.Image: validate_image(file, field_name) raw = await file.read() if len(raw) > MAX_SIZE_MB * 1024 * 1024: raise HTTPException( status_code=400, detail=f"Ukuran file '{field_name}' terlalu besar. Maksimal {MAX_SIZE_MB}MB." ) try: img = Image.open(io.BytesIO(raw)).convert("RGB") except Exception: raise HTTPException( status_code=400, detail=f"File '{field_name}' tidak dapat dibaca sebagai gambar." ) return img # ── Endpoints ───────────────────────────────────────────────────────────────── @app.get("/health", response_model=HealthResponse, tags=["System"]) def health(): """Cek status server dan apakah model sudah siap.""" import torch device = "cuda" if torch.cuda.is_available() else "cpu" return HealthResponse( status="ok", model_ready=is_model_ready(), device=device, timestamp=datetime.now().isoformat(), ) @app.post( "/predict", response_model=PredictResponse, tags=["Prediction"], summary="Prediksi grade kualitas ikan", responses={ 200: {"description": "Prediksi berhasil"}, 400: {"description": "File tidak valid"}, 503: {"description": "Model belum siap"}, }, ) async def predict( eye: UploadFile = File(..., description="Foto mata ikan (JPG/PNG)"), gill: UploadFile = File(..., description="Foto insang ikan (JPG/PNG)"), ): """ **Upload dua gambar:** - `eye` → foto close-up mata ikan - `gill` → foto close-up insang ikan **Response:** grade A / B / C beserta detail confidence per bagian. """ # Cek model if not is_model_ready(): raise HTTPException( status_code=503, detail="Model belum tersedia. Jalankan train.py terlebih dahulu." ) # Baca & validasi gambar eye_img = await read_image(eye, "eye") gill_img = await read_image(gill, "gill") # Jalankan inferensi try: start_t = time.time() result = classify_fish(eye_img, gill_img) elapsed_ms = (time.time() - start_t) * 1000 except Exception as e: logger.error(f"Inference error: {e}") raise HTTPException( status_code=500, detail=f"Terjadi error saat analisis: {str(e)}" ) # Susun response return PredictResponse( grade = result["grade"], label = result["label"], warna = result["color"], deskripsi = result["description"], layak_ekspor = result["grade"] in ("A", "B"), mata=PartResult( kelas = result["eye"]["class"], status = result["eye"]["status"], confidence = round(result["eye"]["confidence"], 4), probabilitas = {k: round(v, 4) for k, v in result["eye"]["probs"].items()}, ), insang=PartResult( kelas = result["gill"]["class"], status = result["gill"]["status"], confidence = round(result["gill"]["confidence"], 4), probabilitas = {k: round(v, 4) for k, v in result["gill"]["probs"].items()}, ), waktu_proses_ms = round(elapsed_ms, 2), timestamp = datetime.now().isoformat(), ) # ── Error handlers ──────────────────────────────────────────────────────────── @app.exception_handler(404) async def not_found(request: Request, exc): return JSONResponse( status_code=404, content={"detail": f"Endpoint '{request.url.path}' tidak ditemukan."} ) @app.exception_handler(500) async def server_error(request: Request, exc): return JSONResponse( status_code=500, content={"detail": "Internal server error. Cek log server."} )