| import sys |
| import locale |
| |
| 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) |
|
|
|
|
| |
| @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>""" |
|
|
|
|
| |
| 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, |
| } |
|
|
|
|
| |
| @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) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| @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", |
| } |
|
|