import sys
import locale
# Force UTF-8 BEFORE any other import
try:
locale.setlocale(locale.LC_ALL, "C.UTF-8")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
import numpy as np
import cv2
import base64
import logging
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, HTMLResponse
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
from face_detection import crop_face, detect_face, draw_face_box, _crop_region
from predict import detect_acne
from severity import calculate_severity
from treatment import get_consultation
from rules import get_expert_recommendation
app = FastAPI(title="Acne Detection API", version="1.0.0")
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=500,
content={"detail": "Terjadi kesalahan server internal"}
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/favicon.ico")
async def favicon():
return Response(status_code=204)
# -- HTML UI ---------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def index():
return """
Acne Detection - Interactive Test
Confidence Threshold
Default: 0.05
IoU Threshold (NMS)
Default: 0.45
Face Zoom
Default: 0.20
Face Shift
Default: 0.15 (frontal)
Face Zoom - Side Profile
Default: 0.30 (side profile, zoom out)
Face Shift - Side Profile
Default: 0.15 (side profile)
Status
Upload gambar untuk memulai tes
Original
-
Face Detection
-
Acne Detections
-
"""
# -- helpers ----------------------------------------------------------
async def _run_prediction(image_array, conf_threshold, iou_threshold, padding, shift_up=0.15,
padding_side=0.30, shift_up_side=0.15, skin_type="berminyak"):
face_result = detect_face(image_array)
face_crop = crop_face(image_array, padding=padding, shift_up=shift_up,
padding_side=padding_side, shift_up_side=shift_up_side)
target = face_crop if face_crop is not None else image_array
result = detect_acne(target, conf_threshold=conf_threshold, iou_threshold=iou_threshold)
severity = calculate_severity(result["summary"])
expert = get_expert_recommendation(
detected_classes=result["detected_classes"],
summary=result["summary"],
skin_type=skin_type,
severity_level=severity["level"],
)
if face_result is not None:
x, y, fw, fh = face_result["bounds"]
orientation = face_result.get("orientation", "frontal")
p = padding_side if orientation == "side_profile" else padding
s = shift_up_side if orientation == "side_profile" else shift_up
face_boxed = draw_face_box(image_array, face_result)
face_crop_boxed = _crop_region(face_boxed, x, y, fw, fh, p, shift_up=s)
_, buffer = cv2.imencode('.jpg', face_crop_boxed, [cv2.IMWRITE_JPEG_QUALITY, 85])
face_image_b64 = base64.b64encode(buffer).decode('utf-8')
else:
face_image_b64 = None
face_info = None
if face_result is not None:
x, y, w, h = face_result["bounds"]
face_info = {
"detected": True,
"method": face_result["method"],
"score": face_result["score"],
"bounds": {"x": x, "y": y, "w": w, "h": h},
"orientation": face_result.get("orientation", "frontal"),
}
else:
face_info = {"detected": False, "method": None, "score": None, "bounds": None, "orientation": None}
return {
"status": "success",
"face_detected": face_crop is not image_array,
"face_info": face_info,
"face_image": face_image_b64,
"severity": severity,
"expert_rule": expert["expert_rule"],
"recommendation": expert["recommendation"],
"daily_skincare": expert["daily_skincare"],
"nonmedikamentosa": expert["nonmedikamentosa"],
"maintenance": expert["maintenance"],
"active_ingredient_info": expert["active_ingredient_info"],
"nodul_alert": expert.get("nodul_alert"),
"consultation": get_consultation(severity["level"]),
**result,
}
# -- API: upload file (multipart) ------------------------------------
@app.post("/api/predict")
async def predict(
image: UploadFile = File(..., description="Gambar wajah untuk dideteksi"),
conf_threshold: float = Query(0.05, ge=0.05, le=1.0),
iou_threshold: float = Query(0.45, ge=0.0, le=1.0),
padding: float = Query(0.2, ge=0.0, le=0.5),
shift_up: float = Query(0.15, ge=0.0, le=0.5),
padding_side: float = Query(0.30, ge=0.0, le=0.5),
shift_up_side: float = Query(0.15, ge=0.0, le=0.5),
skin_type: str = Query("berminyak", description="Tipe kulit: berminyak, kering, sensitif, kombinasi"),
):
if not image.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File harus berupa gambar.")
raw = await image.read()
image_array = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image_array is None:
raise HTTPException(status_code=400, detail="Gagal membaca gambar.")
return await _run_prediction(image_array, conf_threshold, iou_threshold, padding, shift_up,
padding_side, shift_up_side, skin_type)
# -- API: JSON body (base64) -----------------------------------------
class PredictRequest(BaseModel):
image: str
conf_threshold: float = 0.05
iou_threshold: float = 0.45
padding: float = 0.2
shift_up: float = 0.15
padding_side: float = 0.30
shift_up_side: float = 0.15
skin_type: str = "berminyak"
@app.post("/api/predict/json")
async def predict_json(body: PredictRequest):
try:
raw = base64.b64decode(body.image)
except Exception:
raise HTTPException(status_code=400, detail="Gagal decode base64.")
image_array = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image_array is None:
raise HTTPException(status_code=400, detail="Gagal membaca gambar.")
return await _run_prediction(image_array, body.conf_threshold, body.iou_threshold,
body.padding, body.shift_up, body.padding_side, body.shift_up_side,
body.skin_type)
# -- API: return annotated image directly as JPEG -------------------
@app.post("/api/predict/image")
async def predict_image(
image: UploadFile = File(..., description="Gambar wajah untuk dideteksi"),
conf_threshold: float = Query(
0.05, ge=0.05, le=1.0,
description="Confidence threshold (0.05-1.0)",
),
iou_threshold: float = Query(
0.45, ge=0.0, le=1.0,
description="IoU threshold untuk NMS (0.0-1.0)",
),
padding: float = Query(
0.3, ge=0.0, le=0.5,
description="Face crop padding untuk frontal (0.0-0.5)",
),
shift_up: float = Query(
0.15, ge=0.0, le=0.5,
description="Shift crop ke atas untuk frontal (0.0-0.5)",
),
padding_side: float = Query(
0.30, ge=0.0, le=0.5,
description="Face crop padding untuk side profile (0.0-0.5)",
),
shift_up_side: float = Query(
0.15, ge=0.0, le=0.5,
description="Shift crop ke atas untuk side profile (0.0-0.5)",
),
):
"""Return gambar annotated langsung sebagai JPEG."""
if not image.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File harus berupa gambar.")
raw = await image.read()
image_array = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image_array is None:
raise HTTPException(status_code=400, detail="Gagal membaca gambar.")
face_crop = crop_face(image_array, padding=padding, shift_up=shift_up,
padding_side=padding_side, shift_up_side=shift_up_side)
target = face_crop if face_crop is not None else image_array
result = detect_acne(target, conf_threshold=conf_threshold, iou_threshold=iou_threshold)
img_bytes = base64.b64decode(result["annotated_image"])
return Response(content=img_bytes, media_type="image/jpeg")
@app.get("/api/classes")
def get_classes():
return {
"classes": ["comedone", "nodules", "papules", "pustules"],
"model": "YOLOv26s",
"input_size": "640x640",
}