my_app_jun / app.py
shareholder's picture
Update app.py
7565998 verified
Raw
History Blame Contribute Delete
56.4 kB
import sys
import os
import warnings
warnings.filterwarnings("ignore", message=".*HF_TOKEN.*")
import gradio as gr
import torch, torch.nn as nn, timm
from torchvision import models, transforms
from huggingface_hub import hf_hub_download
from PIL import Image
import numpy as np
import random
# ──────────────────────────────────────────
# 1. 5개 AI 모델 로드 (Space 시작 시 1번만)
# ──────────────────────────────────────────
REPO_ID = "junkim1209/isic-models"
MODEL_NAMES = ['efficientnet_b3', 'efficientnet_b5',
'convnext_tiny', 'convnext_base', 'swin_base']
def build_model(name):
if name == 'efficientnet_b3':
m = models.efficientnet_b3(weights=None)
m.classifier[1] = nn.Linear(m.classifier[1].in_features, 1); return m, 300
elif name == 'efficientnet_b5':
return timm.create_model('efficientnet_b5', pretrained=False, num_classes=1), 456
elif name == 'convnext_tiny':
m = models.convnext_tiny(weights=None)
m.classifier[2] = nn.Linear(m.classifier[2].in_features, 1); return m, 224
elif name == 'convnext_base':
return timm.create_model('convnext_base', pretrained=False, num_classes=1), 224
elif name == 'swin_base':
return timm.create_model('swin_base_patch4_window12_384',
pretrained=False, num_classes=1), 384
print("📥 모델 로드 중...")
LOADED = {}
for name in MODEL_NAMES:
path = hf_hub_download(repo_id=REPO_ID, filename=f"{name}.pth")
model, size = build_model(name)
state = torch.load(path, map_location='cpu', weights_only=False)
model.load_state_dict(state['model_state_dict'], strict=True)
model.eval()
LOADED[name] = {'model': model, 'size': size}
print("✅ 준비 완료")
# ──────────────────────────────────────────
# 2. 본인 1일차 가중치 (⭐ 수정)
# ──────────────────────────────────────────
MY_WEIGHTS = {
'efficientnet_b3': 0.3779502616311788, 'efficientnet_b5': 0.2031553438850185,
'convnext_tiny': 0.016351817610742503, 'convnext_base': 0.20957851686980142,
'swin_base': 0.1929640600032588,
}
# ──────────────────────────────────────────
# 3. 추론 함수
# ──────────────────────────────────────────
def predict(image):
if image is None:
return {"📷 사진을 업로드하거나 촬영해주세요": 1.0}
img = Image.fromarray(image).convert('RGB')
probs = {}
for name, info in LOADED.items():
tfm = transforms.Compose([
transforms.Resize((info['size'], info['size'])),
transforms.ToTensor(),
transforms.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225]),
])
x = tfm(img).unsqueeze(0)
with torch.no_grad():
probs[name] = torch.sigmoid(info['model'](x)).item()
total = sum(MY_WEIGHTS.values())
final = sum(probs[k]*w/total for k,w in MY_WEIGHTS.items())
return {"멜라노마 의심": float(final), "양성 모반": float(1-final)}
# --- 📱 병맛 곡선 애니메이션 풀버전 CSS ---
css = """
/* 앱 전체 컨테이너 */
.gradio-container {
max-width: 420px !important;
margin: 15px auto !important;
border: 4px solid #6c5ce7 !important;
border-radius: 32px !important;
box-shadow: 0 20px 60px rgba(108,92,231,0.3) !important;
background: linear-gradient(135deg, #f8f9ff 0%, #e8f4f8 100%);
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
}
/* 입력 요소들이 가려지지 않도록 보장 */
.gradio-container input, .gradio-container label, .gradio-container .gr-button {
position: relative;
z-index: 20 !important;
pointer-events: auto !important;
}
/* 🌈 메인 타이틀 - 출렁이는 효과 */
.main-title {
text-align: center;
font-size: 2.5em;
font-weight: 900;
background: linear-gradient(135deg, #6c5ce7, #a29bfe, #fd79a8, #fdcb6e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-top: 25px;
letter-spacing: -1px;
animation: title-wobble 2s ease-in-out infinite;
}
@keyframes title-wobble {
0%, 100% { transform: rotate(-1deg) scale(1); }
50% { transform: rotate(1deg) scale(1.02); }
}
.sub-title {
text-align: center;
font-size: 1.05em;
color: #6c5ce7;
margin: 15px 20px 25px 20px;
font-weight: bold;
line-height: 1.5;
background: #fff;
padding: 12px 20px;
border-radius: 16px;
border: 2px dashed #a29bfe;
animation: sub-float 3s ease-in-out infinite;
}
@keyframes sub-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
/* ==========================================
🏠 홈 화면 - 병맛 점 캐릭터 (곡선 팔다리)
========================================== */
.home-stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 20px;
height: 220px;
background: linear-gradient(180deg, #ffeaa7 0%, #fdcb6e 100%);
border-radius: 24px;
border: 3px solid #f39c12;
position: relative;
overflow: hidden;
}
/* 떠다니는 구름들 */
.cloud {
position: absolute;
background: #fff;
border-radius: 50px;
opacity: 0.8;
animation: cloud-drift 8s linear infinite;
}
.cloud::before, .cloud::after {
content: '';
position: absolute;
background: #fff;
border-radius: 50%;
}
.cloud-1 {
width: 50px; height: 20px;
top: 20px; left: -60px;
animation-delay: 0s;
}
.cloud-1::before { width: 25px; height: 25px; top: -12px; left: 8px; }
.cloud-1::after { width: 18px; height: 18px; top: -8px; left: 28px; }
.cloud-2 {
width: 40px; height: 16px;
top: 50px; left: -50px;
animation-delay: 3s;
animation-duration: 10s;
}
.cloud-2::before { width: 20px; height: 20px; top: -10px; left: 5px; }
.cloud-2::after { width: 15px; height: 15px; top: -6px; left: 20px; }
@keyframes cloud-drift {
from { transform: translateX(0); }
to { transform: translateX(500px); }
}
/* 반짝이 별 */
.sparkle {
position: absolute;
width: 12px;
height: 12px;
background: #fff;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
animation: twinkle 1.5s infinite;
}
.sparkle-1 { top: 25px; left: 30px; animation-delay: 0s; }
.sparkle-2 { top: 40px; right: 40px; animation-delay: 0.3s; width: 16px; height: 16px; }
.sparkle-3 { bottom: 50px; left: 50px; animation-delay: 0.6s; width: 10px; height: 10px; }
.sparkle-4 { bottom: 35px; right: 35px; animation-delay: 0.9s; }
@keyframes twinkle {
0%, 100% { opacity: 1; transform: scale(1) rotate(0deg); }
50% { opacity: 0.3; transform: scale(0.6) rotate(180deg); }
}
/* 메인 점 캐릭터 - 몸통 */
.mole-character {
position: relative;
width: 80px;
height: 80px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
box-shadow: 0 12px 30px rgba(0,0,0,0.3), inset -6px -6px 15px rgba(0,0,0,0.4), inset 6px 6px 15px rgba(255,255,255,0.1);
animation: mole-bounce 0.6s ease-in-out infinite alternate;
z-index: 10;
}
@keyframes mole-bounce {
from { transform: translateY(0) rotate(-3deg); }
to { transform: translateY(-20px) rotate(3deg); }
}
/* 눈 */
.mole-eye {
position: absolute;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
top: 22px;
}
.mole-eye.left { left: 15px; }
.mole-eye.right { right: 15px; }
/* 눈동자 - 굴러다님 */
.mole-pupil {
position: absolute;
width: 9px;
height: 9px;
background: #2d3436;
border-radius: 50%;
top: 26px;
animation: pupil-roll 2s ease-in-out infinite;
}
.mole-pupil.left { left: 19px; }
.mole-pupil.right { right: 19px; }
@keyframes pupil-roll {
0%, 100% { transform: translate(0, 0); }
25% { transform: translate(3px, 2px); }
50% { transform: translate(0, 3px); }
75% { transform: translate(-3px, 2px); }
}
/* 눈썹 - 꿈틀 */
.mole-brow {
position: absolute;
width: 14px;
height: 6px;
border: 3px solid #fff;
border-bottom: none;
border-radius: 14px 14px 0 0;
top: 14px;
animation: brow-wiggle 1.5s ease-in-out infinite;
}
.mole-brow.left { left: 17px; animation-delay: 0s; }
.mole-brow.right { right: 17px; animation-delay: 0.2s; }
@keyframes brow-wiggle {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-3px) rotate(5deg); }
}
/* 볼터치 - 두근두근 */
.mole-blush {
position: absolute;
width: 14px;
height: 8px;
background: #e17055;
border-radius: 50%;
top: 45px;
opacity: 0.7;
animation: blush-pulse 1s ease-in-out infinite;
}
.mole-blush.left { left: 6px; }
.mole-blush.right { right: 6px; animation-delay: 0.5s; }
@keyframes blush-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.2); }
}
/* 입 - 쫑긋 */
.mole-mouth {
position: absolute;
width: 20px;
height: 10px;
border: 3px solid #fff;
border-top: none;
border-radius: 0 0 20px 20px;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
animation: mouth-talk 0.8s ease-in-out infinite;
}
@keyframes mouth-talk {
0%, 100% { height: 10px; }
50% { height: 14px; }
}
/* 🦾 곡선 팔 (SVG 스타일 곡선) */
.mole-arm-wrap {
position: absolute;
top: -5px;
width: 45px;
height: 50px;
}
.mole-arm-wrap.left { left: -40px; }
.mole-arm-wrap.right { right: -40px; transform: scaleX(-1); }
.mole-arm-curve {
position: absolute;
width: 40px;
height: 45px;
border: 6px solid transparent;
border-left: 6px solid #2d3436;
border-radius: 50% 0 0 50%;
transform-origin: bottom right;
animation: arm-wave 0.4s ease-in-out infinite alternate;
box-shadow: -2px 0 0 0 #636e72;
}
.mole-arm-wrap.left .mole-arm-curve { animation-delay: 0s; }
.mole-arm-wrap.right .mole-arm-curve { animation-delay: 0.2s; }
@keyframes arm-wave {
from { transform: rotate(-20deg); }
to { transform: rotate(-50deg); }
}
/* 손 - 동글동글 */
.mole-hand {
position: absolute;
width: 14px;
height: 14px;
background: radial-gradient(circle at 40% 40%, #636e72, #2d3436);
border-radius: 50%;
top: -8px;
left: -2px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
/* 🦵 곡선 다리 */
.mole-legs {
position: absolute;
bottom: -35px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
}
.mole-leg-wrap {
width: 25px;
height: 35px;
position: relative;
}
.mole-leg-curve {
position: absolute;
width: 20px;
height: 30px;
border: 5px solid transparent;
border-left: 5px solid #2d3436;
border-radius: 0 0 0 50%;
transform-origin: top center;
box-shadow: -2px 0 0 0 #636e72;
}
.mole-leg-wrap.left .mole-leg-curve {
border-radius: 0 0 0 50%;
animation: leg-kick-l 0.6s ease-in-out infinite alternate;
}
.mole-leg-wrap.right .mole-leg-curve {
border-left: none;
border-right: 5px solid #2d3436;
border-radius: 0 0 50% 0;
box-shadow: 2px 0 0 0 #636e72;
animation: leg-kick-r 0.6s ease-in-out infinite alternate-reverse;
}
@keyframes leg-kick-l {
from { transform: rotate(10deg); }
to { transform: rotate(-15deg); }
}
@keyframes leg-kick-r {
from { transform: rotate(-10deg); }
to { transform: rotate(15deg); }
}
/* 발 */
.mole-foot {
position: absolute;
width: 16px;
height: 10px;
background: radial-gradient(ellipse at 50% 30%, #636e72, #2d3436);
border-radius: 50%;
bottom: -5px;
}
.mole-leg-wrap.left .mole-foot { left: -8px; }
.mole-leg-wrap.right .mole-foot { right: -8px; }
/* ==========================================
📝 설문 화면 - 궁금해하는 캐릭터
========================================== */
.survey-stage {
display: flex;
justify-content: center;
align-items: center;
margin: 15px 20px;
padding: 15px;
background: linear-gradient(135deg, #dfe6e9, #b2bec3);
border-radius: 20px;
border: 3px solid #636e72;
}
.curious-mole {
position: relative;
width: 50px;
height: 50px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
animation: curious-tilt 2s ease-in-out infinite;
}
@keyframes curious-tilt {
0%, 100% { transform: rotate(0deg) translateY(0); }
25% { transform: rotate(-15deg) translateY(-5px); }
75% { transform: rotate(15deg) translateY(-5px); }
}
.curious-eye {
position: absolute;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
top: 15px;
}
.curious-eye.left { left: 10px; }
.curious-eye.right { right: 10px; }
.curious-pupil {
position: absolute;
width: 6px;
height: 6px;
background: #2d3436;
border-radius: 50%;
top: 18px;
animation: curious-look 2s ease-in-out infinite;
}
.curious-pupil.left { left: 13px; }
.curious-pupil.right { right: 13px; }
@keyframes curious-look {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
/* 물음표 */
.question-mark {
position: absolute;
top: -25px;
right: -10px;
font-size: 24px;
font-weight: 900;
color: #6c5ce7;
animation: question-bounce 0.8s ease-in-out infinite;
}
@keyframes question-bounce {
0%, 100% { transform: translateY(0) rotate(-10deg); }
50% { transform: translateY(-8px) rotate(10deg); }
}
.think-bubble {
position: absolute;
top: -15px;
right: -5px;
width: 8px;
height: 8px;
background: #6c5ce7;
border-radius: 50%;
animation: bubble-pop 1s ease-in-out infinite;
}
.think-bubble.b2 { top: -8px; right: 0; width: 5px; height: 5px; animation-delay: 0.3s; }
@keyframes bubble-pop {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.6; }
}
/* ==========================================
📸 촬영 화면 - 기다리는 캐릭터
========================================== */
.camera-stage {
display: flex;
justify-content: center;
align-items: center;
margin: 15px 20px;
padding: 20px;
background: linear-gradient(135deg, #74b9ff, #0984e3);
border-radius: 20px;
border: 3px solid #0984e3;
position: relative;
}
.waiting-mole {
position: relative;
width: 55px;
height: 55px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
animation: waiting-tap 0.5s ease-in-out infinite;
}
@keyframes waiting-tap {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.waiting-eye {
position: absolute;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
top: 16px;
}
.waiting-eye.left { left: 10px; }
.waiting-eye.right { right: 10px; }
/* 졸린 눈 깜빡 */
.waiting-lid {
position: absolute;
width: 14px;
height: 0px;
background: #2d3436;
border-radius: 0 0 14px 14px;
top: 16px;
animation: sleepy-blink 3s ease-in-out infinite;
}
.waiting-lid.left { left: 10px; }
.waiting-lid.right { right: 10px; }
@keyframes sleepy-blink {
0%, 40%, 100% { height: 0px; }
45%, 55% { height: 10px; }
60% { height: 0px; }
80%, 90% { height: 14px; }
}
/* zzZ */
.zzz {
position: absolute;
top: -20px;
right: -15px;
font-weight: 900;
color: #fff;
animation: zzz-float 2s ease-in-out infinite;
}
.zzz-1 { font-size: 14px; top: -15px; right: -10px; animation-delay: 0s; }
.zzz-2 { font-size: 18px; top: -30px; right: -20px; animation-delay: 0.4s; }
.zzz-3 { font-size: 12px; top: -40px; right: -8px; animation-delay: 0.8s; }
@keyframes zzz-float {
0%, 100% { opacity: 0; transform: translateY(5px); }
50% { opacity: 1; transform: translateY(-5px); }
}
/* 카메라 아이콘 */
.camera-icon {
position: absolute;
left: 20px;
width: 40px;
height: 30px;
background: #2d3436;
border-radius: 5px;
animation: camera-flash 2s ease-in-out infinite;
}
.camera-icon::before {
content: '';
position: absolute;
top: -8px;
left: 12px;
width: 16px;
height: 8px;
background: #2d3436;
border-radius: 3px 3px 0 0;
}
.camera-icon::after {
content: '';
position: absolute;
top: 8px;
left: 10px;
width: 20px;
height: 20px;
border: 4px solid #636e72;
border-radius: 50%;
background: radial-gradient(circle, #74b9ff, #0984e3);
}
@keyframes camera-flash {
0%, 90%, 100% { box-shadow: none; }
95% { box-shadow: 0 0 30px #fff, 0 0 60px #fff; }
}
/* ==========================================
📊 결과 화면 상단바
========================================== */
.result-top-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 20px;
padding: 18px 22px;
margin-bottom: 15px;
border: 3px solid #dfe6e9;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
animation: result-pop 0.5s ease-out;
}
@keyframes result-pop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.result-left { display: flex; flex-direction: column; }
.result-label { font-size: 0.85em; color: #636e72; font-weight: 600; margin-bottom: 5px; }
.result-value { font-size: 3.2em; font-weight: 900; line-height: 1; }
.result-value.danger { color: #d63031; animation: value-shake 0.3s ease-in-out infinite; }
.result-value.safe { color: #00b894; animation: value-happy 1s ease-in-out infinite; }
@keyframes value-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
75% { transform: translateX(3px); }
}
@keyframes value-happy {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.result-badge {
font-size: 1em;
font-weight: 800;
padding: 10px 20px;
border-radius: 25px;
color: #fff;
}
.result-badge.danger { background: linear-gradient(135deg, #d63031, #e17055); animation: badge-pulse 0.5s infinite; }
.result-badge.safe { background: linear-gradient(135deg, #00b894, #55efc4); animation: badge-glow 1.5s infinite; }
@keyframes badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes badge-glow {
0%, 100% { box-shadow: 0 0 10px rgba(0,184,148,0.5); }
50% { box-shadow: 0 0 25px rgba(0,184,148,0.8); }
}
/* ==========================================
💥 위험 스테이지 - 곡선 팔다리 괴한
========================================== */
.danger-stage {
background: linear-gradient(180deg, #ffeaa7 0%, #fab1a0 50%, #ff7675 100%);
border: 4px solid #d63031;
border-radius: 24px;
padding: 25px 15px;
margin: 10px 0;
min-height: 240px;
position: relative;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
}
/* 불꽃 배경 */
.flame {
position: absolute;
bottom: 0;
width: 30px;
height: 50px;
background: linear-gradient(180deg, transparent 0%, #fdcb6e 30%, #e17055 60%, #d63031 100%);
border-radius: 50% 50% 20% 20%;
animation: flame-flicker 0.2s infinite;
opacity: 0.7;
}
.flame-1 { left: 10%; height: 40px; animation-delay: 0s; }
.flame-2 { left: 30%; height: 55px; animation-delay: 0.1s; }
.flame-3 { left: 50%; height: 45px; animation-delay: 0.05s; }
.flame-4 { left: 70%; height: 50px; animation-delay: 0.15s; }
.flame-5 { left: 90%; height: 35px; animation-delay: 0.08s; }
@keyframes flame-flicker {
0%, 100% { transform: scaleY(1) scaleX(1); }
50% { transform: scaleY(1.1) scaleX(0.9); }
}
/* 괴한 캐릭터 (곡선 버전) */
.villain {
position: relative;
width: 70px;
height: 140px;
z-index: 10;
}
.villain.right { transform: scaleX(-1); }
/* 머리 */
.villain-head {
width: 38px;
height: 38px;
background: radial-gradient(circle at 35% 35%, #495057, #212529);
border-radius: 50%;
margin: 0 auto;
position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.villain-mask {
position: absolute;
width: 30px;
height: 10px;
background: #000;
top: 12px;
left: 50%;
transform: translateX(-50%);
border-radius: 3px;
}
.villain-eye {
position: absolute;
width: 6px;
height: 6px;
background: #ff4757;
border-radius: 50%;
top: 14px;
box-shadow: 0 0 8px #ff4757;
animation: evil-eye 0.5s infinite;
}
.villain-eye.left { left: 8px; }
.villain-eye.right { right: 8px; }
@keyframes evil-eye {
0%, 100% { box-shadow: 0 0 5px #ff4757; }
50% { box-shadow: 0 0 15px #ff4757, 0 0 25px #ff4757; }
}
/* 몸통 */
.villain-torso {
width: 28px;
height: 35px;
background: linear-gradient(180deg, #2d3436, #1a1a1a);
margin: -5px auto 0;
border-radius: 8px 8px 5px 5px;
position: relative;
}
/* 곡선 팔 + 총 */
.villain-arm-set {
position: absolute;
top: 5px;
right: -32px;
animation: arm-recoil 0.12s infinite;
}
@keyframes arm-recoil {
0%, 100% { transform: translateX(0) rotate(0deg); }
50% { transform: translateX(-4px) rotate(-8deg); }
}
.villain-arm-curve {
width: 28px;
height: 20px;
border: 5px solid transparent;
border-top: 5px solid #2d3436;
border-radius: 50% 50% 0 0;
position: relative;
}
.villain-gun {
position: absolute;
right: -5px;
top: -5px;
width: 24px;
height: 16px;
background: linear-gradient(180deg, #636e72, #2d3436);
border-radius: 4px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.4);
}
.villain-gun::before {
content: '';
position: absolute;
width: 14px;
height: 8px;
background: #2d3436;
bottom: -6px;
left: 3px;
border-radius: 2px;
}
/* 총구 화염 - 삼각 불꽃 */
.muzzle-flash {
position: absolute;
right: -20px;
top: 2px;
width: 0;
height: 0;
border-left: 20px solid #fdcb6e;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
animation: flash-burst 0.1s infinite;
filter: drop-shadow(0 0 8px #f39c12);
}
@keyframes flash-burst {
0%, 100% { opacity: 1; transform: scaleX(1); }
50% { opacity: 0.6; transform: scaleX(0.7); }
}
/* 곡선 불꽃 */
.muzzle-curve {
position: absolute;
right: -30px;
top: -2px;
width: 15px;
height: 25px;
border: 4px solid transparent;
border-right: 4px solid #ff7675;
border-radius: 0 50% 50% 0;
animation: flame-curl 0.1s infinite alternate;
}
@keyframes flame-curl {
from { transform: scaleY(0.8); }
to { transform: scaleY(1.2); }
}
/* 곡선 다리 */
.villain-legs {
display: flex;
justify-content: center;
gap: 6px;
margin-top: -2px;
}
.villain-leg-curve {
width: 18px;
height: 38px;
border: 5px solid transparent;
border-radius: 0 0 40% 40%;
position: relative;
}
.villain-leg-curve.left {
border-left: 5px solid #2d3436;
transform: rotate(8deg);
animation: villain-step-l 0.4s ease-in-out infinite alternate;
}
.villain-leg-curve.right {
border-right: 5px solid #2d3436;
transform: rotate(-8deg);
animation: villain-step-r 0.4s ease-in-out infinite alternate-reverse;
}
@keyframes villain-step-l {
from { transform: rotate(5deg); }
to { transform: rotate(15deg); }
}
@keyframes villain-step-r {
from { transform: rotate(-5deg); }
to { transform: rotate(-15deg); }
}
.villain-foot {
position: absolute;
bottom: -3px;
width: 14px;
height: 8px;
background: #2d3436;
border-radius: 50%;
}
.villain-leg-curve.left .villain-foot { left: -10px; }
.villain-leg-curve.right .villain-foot { right: -10px; }
/* 레이저 빔 - 물결 모양 */
.laser-beam {
position: absolute;
height: 6px;
background: linear-gradient(90deg, #d63031, #ff7675, #fab1a0, #ff7675, #d63031);
box-shadow: 0 0 15px #d63031, 0 0 30px #ff7675;
animation: laser-wave 0.15s infinite;
border-radius: 3px;
}
.laser-beam.left { left: 75px; width: 70px; top: 80px; }
.laser-beam.right { right: 75px; width: 70px; top: 80px; }
@keyframes laser-wave {
0%, 100% { transform: scaleY(1) translateY(0); height: 6px; }
25% { transform: scaleY(1.5) translateY(-2px); height: 4px; }
50% { transform: scaleY(0.8) translateY(2px); height: 8px; }
75% { transform: scaleY(1.3) translateY(-1px); height: 5px; }
}
/* 피격 점 캐릭터 */
.victim-mole {
position: relative;
width: 65px;
height: 65px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
z-index: 10;
animation: victim-shake 0.06s infinite;
box-shadow: 0 0 0 6px #d63031, 0 0 30px rgba(214,48,49,0.6);
}
@keyframes victim-shake {
0% { transform: translate(3px, 2px) rotate(2deg); }
25% { transform: translate(-3px, 2px) rotate(-2deg); }
50% { transform: translate(2px, -3px) rotate(1deg); }
75% { transform: translate(-2px, -2px) rotate(-1deg); }
100% { transform: translate(3px, 2px) rotate(2deg); }
}
/* X_X 눈 */
.victim-x-eye {
position: absolute;
width: 16px;
height: 16px;
top: 20px;
}
.victim-x-eye.left { left: 12px; }
.victim-x-eye.right { right: 12px; }
.victim-x-eye::before, .victim-x-eye::after {
content: '';
position: absolute;
width: 16px;
height: 4px;
background: #fff;
top: 6px;
left: 0;
border-radius: 2px;
}
.victim-x-eye::before { transform: rotate(45deg); }
.victim-x-eye::after { transform: rotate(-45deg); }
/* 흐느끼는 입 */
.victim-mouth-sad {
position: absolute;
width: 18px;
height: 8px;
border: 4px solid #74b9ff;
border-bottom: none;
border-radius: 18px 18px 0 0;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
animation: mouth-tremble 0.2s infinite;
}
@keyframes mouth-tremble {
0%, 100% { transform: translateX(-50%) scaleX(1); }
50% { transform: translateX(-50%) scaleX(0.9); }
}
/* 눈물 - 곡선 흐름 */
.tear-drop {
position: absolute;
width: 10px;
height: 16px;
background: linear-gradient(180deg, #74b9ff, #0984e3);
border-radius: 50% 50% 50% 50% / 30% 30% 70% 70%;
animation: tear-fall 0.6s infinite;
}
.tear-drop.left { top: 40px; left: 16px; animation-delay: 0s; }
.tear-drop.right { top: 40px; right: 16px; animation-delay: 0.3s; }
@keyframes tear-fall {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(25px) scale(0.5); opacity: 0; }
}
/* 스파크 - 별 모양 */
.spark-star {
position: absolute;
width: 22px;
height: 22px;
background: #fdcb6e;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
animation: spark-spin 0.2s infinite;
}
.spark-star.s1 { top: 55px; left: 42%; animation-delay: 0s; }
.spark-star.s2 { top: 95px; left: 38%; animation-delay: 0.07s; width: 18px; height: 18px; }
.spark-star.s3 { top: 75px; right: 40%; animation-delay: 0.14s; width: 16px; height: 16px; }
@keyframes spark-spin {
0%, 100% { transform: scale(0.5) rotate(0deg); opacity: 0.3; }
50% { transform: scale(1.2) rotate(180deg); opacity: 1; }
}
/* ==========================================
🎉 안전 스테이지 - 디스코 파티
========================================== */
.safe-stage {
background: linear-gradient(180deg, #0c0c1e 0%, #1a1a3e 50%, #2d2d5a 100%);
border: 4px solid #a29bfe;
border-radius: 24px;
padding: 20px 15px;
margin: 10px 0;
min-height: 260px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
}
/* 무지개 조명 빔 */
.rainbow-beam {
position: absolute;
width: 35px;
height: 220px;
opacity: 0.5;
transform-origin: top center;
animation: beam-swing 2.5s ease-in-out infinite;
border-radius: 0 0 50% 50%;
}
.rainbow-beam.r1 { background: linear-gradient(180deg, #ff6b6b, transparent); left: 5%; animation-delay: 0s; }
.rainbow-beam.r2 { background: linear-gradient(180deg, #feca57, transparent); left: 22%; animation-delay: 0.5s; }
.rainbow-beam.r3 { background: linear-gradient(180deg, #48dbfb, transparent); left: 39%; animation-delay: 1s; }
.rainbow-beam.r4 { background: linear-gradient(180deg, #ff9ff3, transparent); left: 56%; animation-delay: 1.5s; }
.rainbow-beam.r5 { background: linear-gradient(180deg, #1dd1a1, transparent); left: 73%; animation-delay: 2s; }
.rainbow-beam.r6 { background: linear-gradient(180deg, #a29bfe, transparent); left: 90%; animation-delay: 2.5s; }
@keyframes beam-swing {
0%, 100% { transform: rotate(-20deg); opacity: 0.3; }
50% { transform: rotate(20deg); opacity: 0.6; }
}
/* 떨어지는 색종이 - 곡선 움직임 */
.confetti-piece {
position: absolute;
top: -20px;
animation: confetti-swirl 2.5s linear infinite;
}
.confetti-piece::before {
content: '';
display: block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.confetti-piece.c1 { left: 10%; animation-delay: 0s; }
.confetti-piece.c1::before { background: #ff6b6b; }
.confetti-piece.c2 { left: 25%; animation-delay: 0.3s; }
.confetti-piece.c2::before { background: #feca57; border-radius: 50%; }
.confetti-piece.c3 { left: 40%; animation-delay: 0.6s; }
.confetti-piece.c3::before { background: #48dbfb; }
.confetti-piece.c4 { left: 55%; animation-delay: 0.9s; }
.confetti-piece.c4::before { background: #ff9ff3; border-radius: 50%; }
.confetti-piece.c5 { left: 70%; animation-delay: 1.2s; }
.confetti-piece.c5::before { background: #1dd1a1; }
.confetti-piece.c6 { left: 85%; animation-delay: 1.5s; }
.confetti-piece.c6::before { background: #a29bfe; border-radius: 50%; }
@keyframes confetti-swirl {
0% { transform: translateY(0) rotate(0deg) translateX(0); opacity: 1; }
25% { transform: translateY(65px) rotate(180deg) translateX(15px); }
50% { transform: translateY(130px) rotate(360deg) translateX(-10px); }
75% { transform: translateY(195px) rotate(540deg) translateX(20px); }
100% { transform: translateY(260px) rotate(720deg) translateX(0); opacity: 0; }
}
/* 디스코볼 */
.disco-wrap {
position: relative;
margin-bottom: 15px;
z-index: 10;
}
.disco-string {
width: 3px;
height: 25px;
background: linear-gradient(180deg, #dfe6e9, #636e72);
margin: 0 auto;
}
.disco-ball {
width: 55px;
height: 55px;
background: radial-gradient(circle at 30% 30%, #fff 0%, #dfe6e9 30%, #b2bec3 60%, #636e72 100%);
border-radius: 50%;
position: relative;
box-shadow: 0 0 25px #fff, 0 0 50px #a29bfe, 0 0 75px #fd79a8;
animation: disco-spin 3s linear infinite;
overflow: hidden;
}
@keyframes disco-spin {
100% { transform: rotateY(360deg); }
}
.disco-tiles {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background:
repeating-linear-gradient(0deg, transparent 0px, transparent 6px, rgba(255,255,255,0.5) 6px, rgba(255,255,255,0.5) 8px),
repeating-linear-gradient(90deg, transparent 0px, transparent 6px, rgba(255,255,255,0.5) 6px, rgba(255,255,255,0.5) 8px);
border-radius: 50%;
}
/* 반사광 점 */
.disco-sparkle {
position: absolute;
width: 5px;
height: 5px;
background: #fff;
border-radius: 50%;
animation: sparkle-twinkle 0.8s infinite;
}
.disco-sparkle.ds1 { top: 10px; left: 12px; animation-delay: 0s; }
.disco-sparkle.ds2 { top: 25px; right: 10px; animation-delay: 0.2s; }
.disco-sparkle.ds3 { bottom: 15px; left: 20px; animation-delay: 0.4s; }
.disco-sparkle.ds4 { bottom: 10px; right: 18px; animation-delay: 0.6s; }
@keyframes sparkle-twinkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.5); }
}
/* 댄스 플로어 */
.dance-floor {
display: flex;
justify-content: space-around;
align-items: flex-end;
width: 100%;
z-index: 10;
margin-top: 10px;
}
/* 댄서 (곡선 버전) */
.dancer-dude {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.dancer-dude.left { animation: dance-groove-l 0.4s ease-in-out infinite alternate; }
.dancer-dude.right { animation: dance-groove-r 0.4s ease-in-out infinite alternate-reverse; }
@keyframes dance-groove-l {
0% { transform: translateY(0) rotate(-12deg) scale(1); }
100% { transform: translateY(-15px) rotate(12deg) scale(1.05); }
}
@keyframes dance-groove-r {
0% { transform: translateY(-15px) rotate(12deg) scale(1.05); }
100% { transform: translateY(0) rotate(-12deg) scale(1); }
}
.dancer-head {
width: 28px;
height: 28px;
background: radial-gradient(circle at 35% 35%, #fdcb6e, #f39c12);
border-radius: 50%;
position: relative;
}
/* 선글라스 */
.dancer-shades {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
width: 22px;
height: 8px;
background: #2d3436;
border-radius: 3px;
}
.dancer-shades::before, .dancer-shades::after {
content: '';
position: absolute;
width: 8px;
height: 6px;
background: linear-gradient(135deg, #74b9ff, #0984e3);
top: 1px;
border-radius: 2px;
}
.dancer-shades::before { left: 2px; }
.dancer-shades::after { right: 2px; }
.dancer-body {
width: 20px;
height: 30px;
background: linear-gradient(180deg, #a29bfe, #6c5ce7);
border-radius: 6px 6px 3px 3px;
margin-top: -3px;
position: relative;
}
/* 곡선 팔 (춤추는) */
.dancer-arm {
position: absolute;
width: 15px;
height: 18px;
border: 4px solid transparent;
border-top: 4px solid #6c5ce7;
border-radius: 50% 50% 0 0;
top: 2px;
}
.dancer-arm.left {
left: -12px;
animation: arm-pump-l 0.4s ease-in-out infinite alternate;
}
.dancer-arm.right {
right: -12px;
transform: scaleX(-1);
animation: arm-pump-r 0.4s ease-in-out infinite alternate-reverse;
}
@keyframes arm-pump-l {
from { transform: rotate(-30deg); }
to { transform: rotate(30deg); }
}
@keyframes arm-pump-r {
from { transform: scaleX(-1) rotate(-30deg); }
to { transform: scaleX(-1) rotate(30deg); }
}
/* 곡선 다리 */
.dancer-legs {
display: flex;
gap: 4px;
margin-top: -2px;
}
.dancer-leg {
width: 12px;
height: 22px;
border: 4px solid transparent;
border-radius: 0 0 50% 50%;
}
.dancer-leg.left {
border-left: 4px solid #6c5ce7;
animation: leg-step-l 0.4s ease-in-out infinite alternate;
}
.dancer-leg.right {
border-right: 4px solid #6c5ce7;
animation: leg-step-r 0.4s ease-in-out infinite alternate-reverse;
}
@keyframes leg-step-l {
from { transform: rotate(15deg); }
to { transform: rotate(-10deg); }
}
@keyframes leg-step-r {
from { transform: rotate(-15deg); }
to { transform: rotate(10deg); }
}
/* 행복한 점 캐릭터 */
.party-mole {
position: relative;
width: 58px;
height: 58px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
z-index: 10;
animation: party-jump 0.45s ease-in-out infinite alternate;
box-shadow: 0 0 0 5px #00b894, 0 0 30px rgba(0,184,148,0.7);
}
@keyframes party-jump {
0% { transform: translateY(0) scale(1) rotate(-5deg); }
100% { transform: translateY(-22px) scale(1.1) rotate(5deg); }
}
/* 초승달 눈 (행복) */
.party-eye {
position: absolute;
width: 14px;
height: 7px;
background: #fff;
border-radius: 0 0 14px 14px;
top: 18px;
}
.party-eye.left { left: 10px; }
.party-eye.right { right: 10px; }
/* 활짝 웃는 입 */
.party-mouth {
position: absolute;
width: 22px;
height: 11px;
border: 4px solid #fff;
border-top: none;
border-radius: 0 0 22px 22px;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
animation: mouth-wide 0.45s ease-in-out infinite alternate;
}
@keyframes mouth-wide {
from { width: 22px; }
to { width: 26px; }
}
/* 볼터치 */
.party-blush {
position: absolute;
width: 10px;
height: 6px;
background: #e17055;
border-radius: 50%;
top: 30px;
animation: blush-glow 0.45s infinite alternate;
}
.party-blush.left { left: 5px; }
.party-blush.right { right: 5px; }
@keyframes blush-glow {
from { opacity: 0.5; }
to { opacity: 1; }
}
/* 곡선 팔 (만세) */
.party-arm {
position: absolute;
width: 18px;
height: 25px;
border: 5px solid transparent;
top: -15px;
}
.party-arm.left {
left: -15px;
border-left: 5px solid #2d3436;
border-radius: 50% 0 0 50%;
animation: party-wave-l 0.3s ease-in-out infinite alternate;
}
.party-arm.right {
right: -15px;
border-right: 5px solid #2d3436;
border-radius: 0 50% 50% 0;
animation: party-wave-r 0.3s ease-in-out infinite alternate-reverse;
}
@keyframes party-wave-l {
from { transform: rotate(-10deg); }
to { transform: rotate(-40deg); }
}
@keyframes party-wave-r {
from { transform: rotate(10deg); }
to { transform: rotate(40deg); }
}
/* ==========================================
ℹ️ 정보 화면 - 읽는 캐릭터
========================================== */
.info-stage {
display: flex;
justify-content: center;
align-items: center;
margin: 15px 20px;
padding: 15px;
background: linear-gradient(135deg, #55efc4, #00b894);
border-radius: 20px;
border: 3px solid #00b894;
}
.reading-mole {
position: relative;
width: 50px;
height: 50px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
animation: reading-nod 1.5s ease-in-out infinite;
}
@keyframes reading-nod {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
.reading-eye {
position: absolute;
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
top: 16px;
}
.reading-eye.left { left: 10px; }
.reading-eye.right { right: 10px; }
.reading-pupil {
position: absolute;
width: 5px;
height: 5px;
background: #2d3436;
border-radius: 50%;
top: 20px;
animation: reading-scan 2s ease-in-out infinite;
}
.reading-pupil.left { left: 12px; }
.reading-pupil.right { right: 12px; }
@keyframes reading-scan {
0%, 100% { transform: translateX(-2px); }
50% { transform: translateX(2px); }
}
/* 안경 */
.reading-glasses {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 14px;
border: 3px solid #fdcb6e;
border-radius: 8px;
background: transparent;
}
.reading-glasses::before {
content: '';
position: absolute;
width: 6px;
height: 3px;
background: #fdcb6e;
top: 4px;
left: 50%;
transform: translateX(-50%);
}
/* 책 */
.reading-book {
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 20px;
background: linear-gradient(90deg, #dfe6e9 50%, #fff 50%);
border: 2px solid #636e72;
border-radius: 2px;
animation: book-flip 2s ease-in-out infinite;
}
@keyframes book-flip {
0%, 45%, 55%, 100% { transform: translateX(-50%) rotateY(0deg); }
50% { transform: translateX(-50%) rotateY(20deg); }
}
/* ==========================================
⚠️ 주의사항 화면 - 경고 캐릭터
========================================== */
.warning-stage {
display: flex;
justify-content: center;
align-items: center;
margin: 15px 20px;
padding: 15px;
background: linear-gradient(135deg, #ffeaa7, #fdcb6e);
border-radius: 20px;
border: 3px solid #f39c12;
position: relative;
}
.warning-mole {
position: relative;
width: 50px;
height: 50px;
background: radial-gradient(circle at 35% 35%, #636e72, #2d3436);
border-radius: 50%;
animation: warning-shake 0.5s ease-in-out infinite;
}
@keyframes warning-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px) rotate(-5deg); }
75% { transform: translateX(5px) rotate(5deg); }
}
.warning-eye {
position: absolute;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
top: 14px;
}
.warning-eye.left { left: 10px; }
.warning-eye.right { right: 10px; }
.warning-pupil {
position: absolute;
width: 6px;
height: 6px;
background: #2d3436;
border-radius: 50%;
top: 17px;
}
.warning-pupil.left { left: 13px; }
.warning-pupil.right { right: 13px; }
/* O 입 */
.warning-mouth {
position: absolute;
width: 12px;
height: 12px;
border: 3px solid #fff;
border-radius: 50%;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
animation: mouth-oh 0.5s infinite;
}
@keyframes mouth-oh {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.2); }
}
/* 곡선 손 (양손 X) */
.warning-arm {
position: absolute;
width: 20px;
height: 25px;
border: 4px solid transparent;
top: 10px;
}
.warning-arm.left {
left: -18px;
border-left: 4px solid #2d3436;
border-radius: 50% 0 0 50%;
animation: arm-cross-l 0.5s ease-in-out infinite;
}
.warning-arm.right {
right: -18px;
border-right: 4px solid #2d3436;
border-radius: 0 50% 50% 0;
animation: arm-cross-r 0.5s ease-in-out infinite;
}
@keyframes arm-cross-l {
0%, 100% { transform: rotate(20deg); }
50% { transform: rotate(-20deg); }
}
@keyframes arm-cross-r {
0%, 100% { transform: rotate(-20deg); }
50% { transform: rotate(20deg); }
}
/* 경고 삼각형 */
.warning-sign {
position: absolute;
top: -25px;
right: -10px;
width: 0;
height: 0;
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-bottom: 26px solid #d63031;
animation: sign-blink 0.8s infinite;
}
.warning-sign::after {
content: '!';
position: absolute;
top: 8px;
left: -4px;
color: #fff;
font-weight: 900;
font-size: 14px;
}
@keyframes sign-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 배너 */
.verdict-banner {
text-align: center;
font-size: 1.1em;
font-weight: 800;
padding: 14px 20px;
border-radius: 16px;
margin-top: 12px;
}
.verdict-banner.danger {
background: linear-gradient(135deg, #ffeaa7, #fab1a0);
color: #d63031;
border: 3px solid #e17055;
animation: banner-shake 0.3s infinite;
}
@keyframes banner-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
.verdict-banner.safe {
background: linear-gradient(135deg, #dfe6e9, #b2bec3);
color: #00b894;
border: 3px solid #55efc4;
animation: banner-glow 1.5s infinite;
}
@keyframes banner-glow {
0%, 100% { box-shadow: 0 0 10px rgba(0,184,148,0.3); }
50% { box-shadow: 0 0 25px rgba(0,184,148,0.6); }
}
/* 버튼 */
.gradio-container button.primary {
background: linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%) !important;
border: none !important;
border-radius: 20px !important;
font-weight: 800 !important;
font-size: 1.05em !important;
padding: 12px 28px !important;
box-shadow: 0 6px 20px rgba(108,92,231,0.4) !important;
transition: all 0.3s ease !important;
}
.gradio-container button.primary:hover {
transform: translateY(-3px) !important;
box-shadow: 0 10px 30px rgba(108,92,231,0.5) !important;
}
"""
# --- 분석 로직 ---
def run_diagnosis(image):
if image is None:
top_bar = """
<div class='result-top-bar'>
<div class='result-left'>
<div class='result-label'>오류</div>
<div class='result-value' style='color: #d63031;'>사진 없음</div>
</div>
<div class='result-badge danger'>오류</div>
</div>
"""
art_illustration = """
<div class='danger-stage'>
<div style='color: white; text-align: center; width: 100%; font-size: 1.2em; font-weight: bold;'>
사진이 전송되지 않았습니다.<br>이전 화면으로 돌아가 사진을 다시 선택해주세요.
</div>
</div>
"""
verdict_msg = "<div class='verdict-banner danger'>사진을 업로드하거나 촬영해주세요</div>"
return top_bar, art_illustration, verdict_msg, 0
# 이미지 로드 및 변환
if isinstance(image, str):
img = Image.open(image).convert('RGB')
else:
img = Image.fromarray(image).convert('RGB')
# 모델 추론
probs = {}
for name, info in LOADED.items():
tfm = transforms.Compose([
transforms.Resize((info['size'], info['size'])),
transforms.ToTensor(),
transforms.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225]),
])
x = tfm(img).unsqueeze(0)
with torch.no_grad():
probs[name] = torch.sigmoid(info['model'](x)).item()
# 가중 합 계산
total = sum(MY_WEIGHTS.values())
final = sum(probs[k]*w/total for k,w in MY_WEIGHTS.items())
score = int(round(final * 100))
if score >= 50:
top_bar = f"""
<div class='result-top-bar'>
<div class='result-left'>
<div class='result-label'>당신의 점이 흑색종일 가능성</div>
<div class='result-value danger'>{score}%</div>
</div>
<div class='result-badge danger'>나쁨</div>
</div>
"""
art_illustration = """
<div class='danger-stage'>
<div class='flame flame-1'></div><div class='flame flame-2'></div><div class='flame flame-3'></div><div class='flame flame-4'></div><div class='flame flame-5'></div>
<div class='villain'>
<div class='villain-head'><div class='villain-mask'></div><div class='villain-eye left'></div><div class='villain-eye right'></div></div>
<div class='villain-torso'><div class='villain-arm-set'><div class='villain-arm-curve'></div><div class='villain-gun'><div class='muzzle-flash'></div><div class='muzzle-curve'></div></div></div></div>
<div class='villain-legs'><div class='villain-leg-curve left'><div class='villain-foot'></div></div><div class='villain-leg-curve right'><div class='villain-foot'></div></div></div>
</div>
<div class='laser-beam left'></div><div class='spark-star s1'></div><div class='spark-star s2'></div><div class='spark-star s3'></div>
<div class='victim-mole'><div class='victim-x-eye left'></div><div class='victim-x-eye right'></div><div class='victim-mouth-sad'></div><div class='tear-drop left'></div><div class='tear-drop right'></div></div>
<div class='laser-beam right'></div>
</div>
"""
verdict_msg = "<div class='verdict-banner danger'>피부과 방문을 권고드립니다</div>"
else:
top_bar = f"""
<div class='result-top-bar'>
<div class='result-left'>
<div class='result-label'>당신의 점이 흑색종일 가능성</div>
<div class='result-value safe'>{score}%</div>
</div>
<div class='result-badge safe'>좋음</div>
</div>
"""
art_illustration = """
<div class='safe-stage'>
<div class='rainbow-beam r1'></div><div class='rainbow-beam r2'></div><div class='rainbow-beam r3'></div>
<div class='disco-wrap'><div class='disco-string'></div><div class='disco-ball'><div class='disco-tiles'></div></div></div>
<div class='dance-floor'>
<div class='dancer-dude left'><div class='dancer-head'><div class='dancer-shades'></div></div><div class='dancer-body'></div></div>
<div class='party-mole'><div class='party-eye left'></div><div class='party-eye right'></div><div class='party-mouth'></div><div class='party-arm left'></div><div class='party-arm right'></div></div>
<div class='dancer-dude right'><div class='dancer-head'><div class='dancer-shades'></div></div><div class='dancer-body'></div></div>
</div>
</div>
"""
verdict_msg = "<div class='verdict-banner safe'>이상 무! 아주 깨끗하고 안전합니다</div>"
return top_bar, art_illustration, verdict_msg, score
# --- 네비게이션 ---
def move_s2(): return gr.update(visible=False), gr.update(visible=True)
def move_s3(): return gr.update(visible=False), gr.update(visible=True)
def move_s4(img):
t, a, m, s = run_diagnosis(img)
return gr.update(visible=False), gr.update(visible=True), t, a, m, s
def move_s5():
return gr.update(visible=False), gr.update(visible=True)
def move_s6(): return gr.update(visible=False), gr.update(visible=True)
def back_home(): return gr.update(visible=True), gr.update(visible=False), None
# --- UI ---
with gr.Blocks(css=css, theme=gr.themes.Default()) as demo:
# 화면 1: 홈
with gr.Column(visible=True) as s1:
gr.HTML("<div class='main-title'>점벼라 세상아!</div>")
gr.HTML("""
<div class='home-stage'>
<div class='cloud cloud-1'></div>
<div class='cloud cloud-2'></div>
<div class='sparkle sparkle-1'></div>
<div class='sparkle sparkle-2'></div>
<div class='sparkle sparkle-3'></div>
<div class='sparkle sparkle-4'></div>
<div class='mole-character'>
<div class='mole-arm-wrap left'>
<div class='mole-arm-curve'>
<div class='mole-hand'></div>
</div>
</div>
<div class='mole-arm-wrap right'>
<div class='mole-arm-curve'>
<div class='mole-hand'></div>
</div>
</div>
<div class='mole-brow left'></div>
<div class='mole-brow right'></div>
<div class='mole-eye left'></div>
<div class='mole-eye right'></div>
<div class='mole-pupil left'></div>
<div class='mole-pupil right'></div>
<div class='mole-blush left'></div>
<div class='mole-blush right'></div>
<div class='mole-mouth'></div>
<div class='mole-legs'>
<div class='mole-leg-wrap left'>
<div class='mole-leg-curve'></div>
<div class='mole-foot'></div>
</div>
<div class='mole-leg-wrap right'>
<div class='mole-leg-curve'></div>
<div class='mole-foot'></div>
</div>
</div>
</div>
</div>
""")
gr.HTML("<div class='sub-title'>스마트폰으로 간편하게<br>흑색종 위험 판단하기</div>")
btn_s1 = gr.Button("점 진단하기", variant="primary", size="lg")
# 화면 2: 설문
with gr.Column(visible=False) as s2:
gr.HTML("""
<div class='survey-stage'>
<div class='curious-mole'>
<div class='curious-eye left'></div>
<div class='curious-eye right'></div>
<div class='curious-pupil left'></div>
<div class='curious-pupil right'></div>
<div class='question-mark'>?</div>
<div class='think-bubble'></div>
<div class='think-bubble b2'></div>
</div>
</div>
""")
gr.Markdown("### 단계 1. 설문 작성")
age = gr.Number(label="당신의 만나이는?", value=24, interactive=True)
history = gr.Radio(["있음", "없음"], label="가족 내 피부암 환자가 있었나요?", value="없음", interactive=True)
change = gr.Radio(["크기가 크게 확장됨", "색상이 얼룩덜룩해짐", "경계선이 뭉개짐", "변화 없음"], label="최근 해당 점의 형태적 변화가 있었습니까?", value="변화 없음", interactive=True)
btn_s2 = gr.Button("다음 단계", variant="primary")
# 화면 3: 촬영
with gr.Column(visible=False) as s3:
gr.HTML("""
<div class='camera-stage'>
<div class='camera-icon'></div>
<div class='waiting-mole'>
<div class='waiting-eye left'></div>
<div class='waiting-eye right'></div>
<div class='waiting-lid left'></div>
<div class='waiting-lid right'></div>
<div class='zzz zzz-1'>z</div>
<div class='zzz zzz-2'>Z</div>
<div class='zzz zzz-3'>z</div>
</div>
</div>
""")
gr.Markdown("### 단계 2. 이미지 촬영")
image_input = gr.Image(sources=["upload", "webcam"], type="filepath", label="카메라로 촬영하거나 사진 선택")
btn_s3 = gr.Button("진단 실행", variant="primary")
# 화면 4: 결과
with gr.Column(visible=False) as s4:
out_top = gr.HTML()
out_anim = gr.HTML()
out_msg = gr.HTML()
score_state = gr.State() # AI 점수 저장용
btn_s4 = gr.Button("다음", variant="primary")
# 화면 5: 정보
with gr.Column(visible=False) as s5:
gr.HTML("""
<div class='info-stage'>
<div class='reading-mole'>
<div class='reading-eye left'></div>
<div class='reading-eye right'></div>
<div class='reading-pupil left'></div>
<div class='reading-pupil right'></div>
<div class='reading-glasses'></div>
<div class='reading-book'></div>
</div>
</div>
""")
gr.Markdown("### 흑색종 자가 진단 가이드 (ABCDE)")
gr.Markdown("""
* **A (Asymmetry) 비대칭**: 모양이 동그랗지 않고 한쪽으로 일그러짐
* **B (Border) 불규칙 경계**: 가장자리가 매끄럽지 못함
* **C (Color) 다채로운 색**: 연갈색, 검은색, 붉은색 등이 뒤섞임
* **D (Diameter) 큰 지름**: 직경이 6mm 이상
* **E (Evolution) 지속적 변화**: 크기와 깊이가 계속 변함
---
초기 발견 시 **외과 절제 수술만으로 완치 가능**, 전이 시 **치명적**입니다.
""")
btn_s5 = gr.Button("다음", variant="primary")
# 화면 6: 주의사항
with gr.Column(visible=False) as s6:
gr.HTML("""
<div class='warning-stage'>
<div class='warning-mole'>
<div class='warning-eye left'></div>
<div class='warning-eye right'></div>
<div class='warning-pupil left'></div>
<div class='warning-pupil right'></div>
<div class='warning-mouth'></div>
<div class='warning-arm left'></div>
<div class='warning-arm right'></div>
<div class='warning-sign'></div>
</div>
</div>
""")
gr.Markdown("### 프로그램 사용 주의사항")
gr.Markdown("""
본 시뮬레이터의 수치는 **의학적 진단을 대체할 수 없습니다.**
이상이 있다면 **즉시 피부과를 방문**하세요.
---
**제작자:** jj
""")
btn_s6 = gr.Button("처음 화면으로")
btn_s1.click(move_s2, inputs=None, outputs=[s1, s2])
btn_s2.click(move_s3, inputs=None, outputs=[s2, s3])
btn_s3.click(move_s4, inputs=[image_input], outputs=[s3, s4, out_top, out_anim, out_msg, score_state])
btn_s4.click(move_s5, inputs=None, outputs=[s4, s5])
btn_s5.click(move_s6, inputs=None, outputs=[s5, s6])
btn_s6.click(back_home, inputs=None, outputs=[s1, s6, image_input])
demo.launch(debug=True)