Spaces:
Sleeping
Sleeping
| 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) |