acne-api-hf / main.py
ItsFarros's picture
feat: nodul_alert — peringatan nodul terpisah dari treatment
e375d2d verified
Raw
History Blame Contribute Delete
32.6 kB
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 """<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acne Detection - Interactive Test</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter','Segoe UI',sans-serif; background: #0b0d14; color: #e2e8f0; min-height: 100vh; }
/* -- layout -- */
.app { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.header { text-align: center; margin-bottom: 1.5rem; }
.header h1 { font-size: 1.5rem; }
.header .sub { color: #64748b; font-size: 0.8rem; margin-top: 0.2rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; }
@media (max-width: 1000px) { .grid-3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .grid-3 { grid-template-columns: 1fr; } }
.card { background: #161a25; border-radius: 10px; padding: 1rem; border: 1px solid #1e293b; }
.card-title { font-size: 0.8rem; font-weight: 600; color: #93a3d1; margin-bottom: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
/* -- upload -- */
.upload-zone {
border: 2px dashed #334155; border-radius: 10px; padding: 1.5rem; text-align: center;
cursor: pointer; transition: border-color 0.2s, background 0.2s;
}
.upload-zone:hover { border-color: #6366f1; background: #1c1f2e; }
.upload-zone input { display: none; }
.upload-zone .icon { font-size: 2rem; margin-bottom: 0.4rem; }
.upload-zone p { font-size: 0.82rem; color: #64748b; }
/* -- sliders -- */
.slider-group { margin-bottom: 1rem; }
.slider-group:last-child { margin-bottom: 0; }
.slider-group .slider-label {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.3rem;
}
.slider-group .slider-label .val { font-weight: 700; color: #818cf8; min-width: 2.8rem; text-align: right; }
.slider-group input[type=range] { width: 100%; accent-color: #6366f1; height: 5px; cursor: pointer; }
.presets { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
.presets button {
background: #1e293b; color: #94a3b8; border: 1px solid #334155; border-radius: 6px;
padding: 0.25rem 0.55rem; font-size: 0.72rem; cursor: pointer; transition: all 0.15s;
}
.presets button:hover { background: #6366f1; color: #fff; border-color: #6366f1; }
.presets button.active { background: #6366f1; color: #fff; border-color: #6366f1; }
/* -- images -- */
.img-box { text-align: center; }
.img-box .img-wrap {
background: #0b0d14; border-radius: 8px; border: 1px solid #1e293b;
min-height: 180px; display: flex; align-items: center; justify-content: center;
overflow: hidden; position: relative;
}
.img-box .img-wrap img { max-width: 100%; max-height: 340px; display: block; }
.img-box .img-wrap .placeholder { color: #475569; font-size: 0.8rem; }
.img-box .img-label { font-size: 0.72rem; color: #64748b; margin-top: 0.4rem; }
/* -- stats row -- */
.stats-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.stat-chip {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.8rem; font-weight: 600;
border: 1px solid transparent;
}
.stat-chip .num { font-size: 1rem; }
.stat-chip.comedone { background: #422410; color: #facc15; border-color: #5b3a1a; }
.stat-chip.nodules { background: #3f1515; color: #f87171; border-color: #5c2020; }
.stat-chip.papules { background: #3d2310; color: #fb923c; border-color: #543218; }
.stat-chip.pustules { background: #1a1a3f; color: #818cf8; border-color: #2a2a5c; }
.total-badge { font-size: 0.78rem; color: #64748b; margin-left: auto; }
.total-badge strong { color: #e2e8f0; }
/* -- severity -- */
.severity-bar { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.6rem; }
.sev-badge {
padding: 0.25rem 0.7rem; border-radius: 20px; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em;
}
.sev-clear { background: #064e3b; color: #34d399; }
.sev-mild { background: #4a3000; color: #fbbf24; }
.sev-moderate { background: #4a1c00; color: #fb923c; }
.sev-severe { background: #4a0000; color: #f87171; }
.sev-desc { font-size: 0.78rem; color: #64748b; }
/* -- detection table -- */
.detect-table-wrap { overflow-x: auto; margin-top: 0.6rem; }
.detect-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.detect-table th {
text-align: left; padding: 0.4rem 0.6rem; color: #64748b; font-weight: 600;
border-bottom: 1px solid #1e293b; font-size: 0.72rem; text-transform: uppercase;
position: sticky; top: 0; background: #161a25;
}
.detect-table td { padding: 0.35rem 0.6rem; border-bottom: 1px solid #1a1f2e; }
.detect-table tr:hover td { background: #1c1f2e; }
.detect-table .conf-bar-wrap { display: flex; align-items: center; gap: 0.4rem; }
.detect-table .conf-bar {
height: 4px; border-radius: 4px; background: #334155; flex: 1; min-width: 40px; overflow: hidden;
}
.detect-table .conf-bar .fill { height: 100%; border-radius: 4px; transition: width 0.2s; }
.detect-table .conf-num { font-weight: 600; min-width: 2.5rem; text-align: right; }
.detect-table .class-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.35rem;
}
.detect-table .bbox { color: #475569; font-family: monospace; font-size: 0.7rem; }
.row-comedone td:first-child { border-left: 3px solid #FFC800; }
.row-nodules td:first-child { border-left: 3px solid #FF0000; }
.row-papules td:first-child { border-left: 3px solid #FFA500; }
.row-pustules td:first-child { border-left: 3px solid #0000FF; }
/* -- empty / loading / error -- */
.empty-state { text-align: center; padding: 2rem; color: #475569; font-size: 0.85rem; }
.loading-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100;
align-items: center; justify-content: center; backdrop-filter: blur(2px);
}
.loading-overlay.show { display: flex; }
.loading-box {
background: #161a25; padding: 1.5rem 2rem; border-radius: 12px; border: 1px solid #1e293b;
text-align: center;
}
.loading-box .spinner {
display: inline-block; width: 24px; height: 24px; border-radius: 50%;
border: 3px solid #1e293b; border-top-color: #6366f1; animation: spin 0.8s linear infinite;
margin-bottom: 0.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-box p { font-size: 0.82rem; color: #94a3b8; }
.error-box {
display: none; background: #2d0a0a; color: #fca5a5; padding: 0.6rem 1rem; border-radius: 8px;
margin-top: 0.5rem; font-size: 0.8rem; border: 1px solid #4a1515; white-space: pre-wrap;
}
/* -- responsive tweaks -- */
.info-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-box">
<div class="spinner"></div>
<p>Memproses...</p>
</div>
</div>
<div class="app">
<div class="header">
<h1>Acne Detection - Interactive Test</h1>
<p class="sub">Hy-opus Skripsi - YOLOv26s - Geser slider untuk melihat perubahan deteksi secara langsung</p>
</div>
<div class="grid-2">
<!-- -- LEFT: Upload + Controls -- -->
<div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title">Upload</div>
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
<input type="file" id="fileInput" accept="image/*">
<div class="icon">[IMG]</div>
<p>Klik atau drag & drop gambar wajah</p>
</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> Confidence Threshold</div>
<div class="slider-group">
<div class="slider-label">
<span>Min. confidence</span>
<span class="val" id="confVal">0.05</span>
</div>
<input type="range" id="confSlider" min="5" max="95" value="5" step="5" oninput="onSliderChange()">
</div>
<div class="presets" id="confPresets">
<button data-v="5" class="active">0.05</button>
<button data-v="10">0.10</button>
<button data-v="15">0.15</button>
<button data-v="20">0.20</button>
<button data-v="25">0.25</button>
<button data-v="30">0.30</button>
<button data-v="50">0.50</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.05</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> IoU Threshold (NMS)</div>
<div class="slider-group">
<div class="slider-label">
<span>IoU</span>
<span class="val" id="iouVal">0.45</span>
</div>
<input type="range" id="iouSlider" min="10" max="90" value="45" step="5" oninput="onSliderChange()">
</div>
<div class="presets" id="iouPresets">
<button data-v="20">0.20</button>
<button data-v="35">0.35</button>
<button data-v="45" class="active">0.45</button>
<button data-v="60">0.60</button>
<button data-v="80">0.80</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.45</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> Face Zoom</div>
<div class="slider-group">
<div class="slider-label">
<span>Padding (zoom)</span>
<span class="val" id="padVal">0.20</span>
</div>
<input type="range" id="padSlider" min="0" max="50" value="20" step="5" oninput="onSliderChange()">
</div>
<div class="presets" id="padPresets">
<button data-v="0">0.00</button>
<button data-v="10">0.10</button>
<button data-v="15">0.15</button>
<button data-v="20" class="active">0.20</button>
<button data-v="25">0.25</button>
<button data-v="30">0.30</button>
<button data-v="50">0.50</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.20</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> Face Shift</div>
<div class="slider-group">
<div class="slider-label">
<span>Geser ke atas</span>
<span class="val" id="shiftVal">0.15</span>
</div>
<input type="range" id="shiftSlider" min="0" max="50" value="15" step="5" oninput="onSliderChange()">
</div>
<div class="presets" id="shiftPresets">
<button data-v="0">0.00</button>
<button data-v="10">0.10</button>
<button data-v="15" class="active">0.15</button>
<button data-v="25">0.25</button>
<button data-v="40">0.40</button>
<button data-v="50">0.50</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.15 (frontal)</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> Face Zoom - Side Profile</div>
<div class="slider-group">
<div class="slider-label">
<span>Padding (zoom)</span>
<span class="val" id="padSideVal">0.30</span>
</div>
<input type="range" id="padSideSlider" min="0" max="50" value="30" step="1" oninput="onSliderChange()">
</div>
<div class="presets" id="padSidePresets">
<button data-v="0">0.00</button>
<button data-v="10">0.10</button>
<button data-v="15">0.15</button>
<button data-v="20">0.20</button>
<button data-v="25">0.25</button>
<button data-v="30" class="active">0.30</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.30 (side profile, zoom out)</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div class="card-title"> Face Shift - Side Profile</div>
<div class="slider-group">
<div class="slider-label">
<span>Geser ke atas</span>
<span class="val" id="shiftSideVal">0.15</span>
</div>
<input type="range" id="shiftSideSlider" min="0" max="50" value="15" step="5" oninput="onSliderChange()">
</div>
<div class="presets" id="shiftSidePresets">
<button data-v="0">0.00</button>
<button data-v="10">0.10</button>
<button data-v="15" class="active">0.15</button>
<button data-v="20">0.20</button>
<button data-v="25">0.25</button>
<button data-v="35">0.35</button>
<button data-v="50">0.50</button>
</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:0.4rem">Default: 0.15 (side profile)</div>
</div>
<div class="card">
<div class="card-title"> Status</div>
<div id="statusArea">
<div class="empty-state">Upload gambar untuk memulai tes</div>
</div>
</div>
</div>
<!-- -- RIGHT: Images -- -->
<div>
<div class="grid-3" style="margin-bottom:1rem">
<div class="card img-box">
<div class="card-title"> Original</div>
<div class="img-wrap">
<div class="placeholder" id="origPlaceholder">-</div>
<img id="origImg" style="display:none">
</div>
</div>
<div class="card img-box">
<div class="card-title"> Face Detection</div>
<div class="img-wrap">
<div class="placeholder" id="facePlaceholder">-</div>
<img id="faceImg" style="display:none">
</div>
<div id="faceInfo" class="img-label" style="display:none"></div>
</div>
<div class="card img-box">
<div class="card-title"> Acne Detections</div>
<div class="img-wrap">
<div class="placeholder" id="annotPlaceholder">-</div>
<img id="annotImg" style="display:none">
</div>
<div id="annotInfo" class="img-label" style="display:none"></div>
</div>
</div>
<div class="card" id="detectionCard" style="display:none">
<div class="card-title"> Detection List</div>
<div id="detectList"><div class="empty-state">Tidak ada deteksi</div></div>
</div>
<div class="error-box" id="errorBox"></div>
</div>
</div>
</div>
<script>
let selectedFile = null;
let debounceTimer = null;
let allDetections = [];
const CLASS_COLORS = { comedone:'#FFC800', nodules:'#FF0000', papules:'#FFA500', pustules:'#0000FF' };
// -- file handling --
function onFileSelect() {
const input = document.getElementById('fileInput');
selectedFile = input.files[0];
if (!selectedFile) return;
const reader = new FileReader();
reader.onload = e => {
const src = e.target.result;
document.getElementById('origPlaceholder').style.display = 'none';
document.getElementById('origImg').src = src;
document.getElementById('origImg').style.display = 'block';
document.getElementById('errorBox').style.display = 'none';
runDetection();
};
reader.readAsDataURL(selectedFile);
}
const dz = document.getElementById('dropZone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor = '#818cf8'; dz.style.background = '#1c1f2e'; });
dz.addEventListener('dragleave', () => { dz.style.borderColor = '#334155'; dz.style.background = ''; });
dz.addEventListener('drop', e => {
e.preventDefault(); dz.style.borderColor = '#334155'; dz.style.background = '';
if (e.dataTransfer.files.length) {
document.getElementById('fileInput').files = e.dataTransfer.files;
onFileSelect();
}
});
// -- preset buttons --
const PRESET_SLIDER_IDS = {
confPresets: 'confSlider', iouPresets: 'iouSlider',
padPresets: 'padSlider', shiftPresets: 'shiftSlider',
padSidePresets: 'padSideSlider', shiftSidePresets: 'shiftSideSlider',
};
document.querySelectorAll('.presets button').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.v);
const parent = btn.closest('.presets');
const sliderId = PRESET_SLIDER_IDS[parent.id];
if (sliderId) document.getElementById(sliderId).value = val;
onSliderChange();
parent.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// -- slider change --
function onSliderChange() {
const c = parseInt(document.getElementById('confSlider').value);
const i = parseInt(document.getElementById('iouSlider').value);
const p = parseInt(document.getElementById('padSlider').value);
const s = parseInt(document.getElementById('shiftSlider').value);
const ps = parseInt(document.getElementById('padSideSlider').value);
const ss = parseInt(document.getElementById('shiftSideSlider').value);
document.getElementById('confVal').textContent = (c / 100).toFixed(2);
document.getElementById('iouVal').textContent = (i / 100).toFixed(2);
document.getElementById('padVal').textContent = (p / 100).toFixed(2);
document.getElementById('shiftVal').textContent = (s / 100).toFixed(2);
document.getElementById('padSideVal').textContent = (ps / 100).toFixed(2);
document.getElementById('shiftSideVal').textContent = (ss / 100).toFixed(2);
// update active preset
document.querySelectorAll('#confPresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === c));
document.querySelectorAll('#iouPresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === i));
document.querySelectorAll('#padPresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === p));
document.querySelectorAll('#shiftPresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === s));
document.querySelectorAll('#padSidePresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === ps));
document.querySelectorAll('#shiftSidePresets button').forEach(b => b.classList.toggle('active', parseInt(b.dataset.v) === ss));
clearTimeout(debounceTimer);
if (!selectedFile) return;
debounceTimer = setTimeout(runDetection, 200);
}
// -- main detection --
async function runDetection() {
if (!selectedFile) return;
const conf = parseInt(document.getElementById('confSlider').value) / 100;
const iou = parseInt(document.getElementById('iouSlider').value) / 100;
const pad = parseInt(document.getElementById('padSlider').value) / 100;
const shift = parseInt(document.getElementById('shiftSlider').value) / 100;
const padSide = parseInt(document.getElementById('padSideSlider').value) / 100;
const shiftSide = parseInt(document.getElementById('shiftSideSlider').value) / 100;
document.getElementById('loadingOverlay').classList.add('show');
document.getElementById('errorBox').style.display = 'none';
const fd = new FormData();
fd.append('image', selectedFile);
try {
const res = await fetch(`/api/predict?conf_threshold=${conf}&iou_threshold=${iou}&padding=${pad}&shift_up=${shift}&padding_side=${padSide}&shift_up_side=${shiftSide}`, { method: 'POST', body: fd });
if (!res.ok) {
let msg = res.statusText;
try { const d = await res.json(); msg = d.detail || msg; } catch {}
throw new Error(`[${res.status}] ${msg}`);
}
const data = await res.json();
if (!data.annotated_image || data.annotated_image.length < 100)
throw new Error('annotated_image kosong');
displayResults(data, conf, iou);
} catch (err) {
document.getElementById('errorBox').textContent = '[ERR] ' + err.message;
document.getElementById('errorBox').style.display = 'block';
} finally {
document.getElementById('loadingOverlay').classList.remove('show');
}
}
// -- display --
function displayResults(data, confUsed, iouUsed) {
// face detection image
const fi = data.face_info;
document.getElementById('facePlaceholder').style.display = 'none';
if (data.face_image) {
document.getElementById('faceImg').src = 'data:image/jpeg;base64,' + data.face_image;
document.getElementById('faceImg').style.display = 'block';
}
document.getElementById('faceInfo').style.display = 'block';
if (fi && fi.detected) {
document.getElementById('faceInfo').textContent =
`[OK] Wajah terdeteksi - ${fi.method} - ${fi.orientation} - confidence: ${(fi.score * 100).toFixed(1)}% - box: ${fi.bounds.w}x${fi.bounds.h}`;
} else {
document.getElementById('faceInfo').textContent = '[ERR] Wajah tidak terdeteksi (menggunakan gambar asli)';
}
// annotated image
document.getElementById('annotPlaceholder').style.display = 'none';
document.getElementById('annotImg').src = 'data:image/jpeg;base64,' + data.annotated_image;
document.getElementById('annotImg').style.display = 'block';
document.getElementById('detectionCard').style.display = 'block';
// annot info
document.getElementById('annotInfo').style.display = 'block';
document.getElementById('annotInfo').textContent =
`conf: ${confUsed.toFixed(2)} - iou: ${iouUsed.toFixed(2)} - total: ${data.total_detections} deteksi`;
// status area
const sev = data.severity;
const sevLabels = { clear:'BERSIH', mild:'RINGAN', moderate:'SEDANG', severe:'PARAH' };
const cls = ['comedone','nodules','papules','pustules'];
const clsLabels = { comedone:'Komedo', nodules:'Nodul', papules:'Papula', pustules:'Pustula' };
let statsHtml = '<div class="stats-row">';
cls.forEach(c => {
const count = data.summary[c] || 0;
if (count > 0) {
statsHtml += `<span class="stat-chip ${c}"><span class="num">${count}</span> ${clsLabels[c]}</span>`;
}
});
statsHtml += `<span class="total-badge">Total: <strong>${data.total_detections}</strong> deteksi</span>`;
statsHtml += '</div>';
statsHtml += `<div class="severity-bar">
<span class="sev-badge sev-${sev.level}">${sevLabels[sev.level] || sev.level}</span>
<span class="sev-desc">${sev.description}</span>
</div>`;
document.getElementById('statusArea').innerHTML = statsHtml;
// detection table
const detections = data.detections || [];
allDetections = detections;
if (detections.length === 0) {
document.getElementById('detectList').innerHTML = '<div class="empty-state">Tidak ada deteksi pada threshold ini</div>';
return;
}
// sort by confidence descending
const sorted = [...detections].sort((a, b) => b.confidence - a.confidence);
let tableHtml = '<div class="detect-table-wrap"><table class="detect-table"><thead><tr>' +
'<th>#</th><th>Class</th><th>Confidence</th><th>Bounding Box (x1,y1,x2,y2)</th></tr></thead><tbody>';
sorted.forEach((d, i) => {
const clsName = d.class;
const color = CLASS_COLORS[clsName] || '#999';
const confPct = (d.confidence * 100).toFixed(1) + '%';
const barW = (d.confidence * 100).toFixed(0);
const bbox = d.bbox_xyxy ? d.bbox_xyxy.map(v => Math.round(v)).join(', ') : '-';
tableHtml += `<tr class="row-${clsName}">
<td style="color:#475569">${i + 1}</td>
<td><span class="class-dot" style="background:${color}"></span>${clsName}</td>
<td>
<div class="conf-bar-wrap">
<div class="conf-bar"><div class="fill" style="width:${barW}%;background:${color}"></div></div>
<span class="conf-num" style="color:${color}">${confPct}</span>
</div>
</td>
<td class="bbox">${bbox}</td>
</tr>`;
});
tableHtml += '</tbody></table></div>';
document.getElementById('detectList').innerHTML = tableHtml;
}
document.getElementById('fileInput').addEventListener('change', onFileSelect);
</script>
</body>
</html>"""
# -- 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",
}