File size: 9,043 Bytes
ad61cf0
 
 
98af918
 
 
 
 
ad61cf0
98af918
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98af918
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
98af918
ad61cf0
98af918
 
 
 
 
 
 
 
ad61cf0
 
 
 
 
98af918
 
 
 
 
 
 
 
ad61cf0
 
98af918
ad61cf0
98af918
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98af918
 
 
 
 
 
 
 
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98af918
ad61cf0
 
 
 
529a7e4
ad61cf0
 
 
 
 
 
 
 
 
 
98af918
 
 
 
 
ad61cf0
 
 
 
 
98af918
 
 
 
 
 
 
 
529a7e4
ad61cf0
 
98af918
ad61cf0
 
98af918
ad61cf0
 
 
98af918
ad61cf0
 
 
 
 
 
 
 
 
 
 
98af918
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98af918
 
ad61cf0
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
"""
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."}
    )