# ============================================================ # app.py — 그로센딕 AI Day 3 (점집 컨셉 · 따뜻한 버전) # 자연어 프롬프트 기반 디자인 / 무섭지 않고 친근한 점집 # ============================================================ 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 # ────────────────────────────────────────── # 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("📥 무당 5명 모시는 중...") 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': 1.0, 'efficientnet_b5': 1.0, 'convnext_tiny': 1.0, 'convnext_base': 1.0, 'swin_base': 1.0, } # ────────────────────────────────────────── # 3. 추론 + 문진 결합 ※ predict 본체는 유지하고 함수 시그니처만 확장 # ────────────────────────────────────────── def predict_with_questionnaire(image, age, family_history, mole_change): if image is None: return None, "" 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()) ai_prob = sum(probs[k] * w / total for k, w in MY_WEIGHTS.items()) bonus = 0.0 if family_history == "예": bonus += 0.10 if mole_change == "예": bonus += 0.15 if age >= 50: bonus += 0.05 final = min(ai_prob + bonus, 1.0) label_data = { "흉(凶) — 멜라노마 의심": float(final), "길(吉) — 양성 모반": float(1 - final), } # 위험도별 점괘 풀이 (따뜻한 톤) if final < 0.20: tag = "길조 (吉兆)" msg = "이 점은 평온한 점이라네.
일반적인 모반으로 보이오. 평소처럼 자가 관찰을 이어가시오." tag_color = "#3F8B5F" # 부드러운 초록 tag_bg = "#EDF6EF" elif final < 0.50: tag = "주의 (注意)" msg = "조금 신경 쓰이는 기운이 있구려.
가까운 피부과에서 한번 봐달라 하시오." tag_color = "#C9722E" # 따뜻한 주황 tag_bg = "#FCF1E0" else: tag = "흉조 (凶兆)" msg = "이 점은 좀 살펴봐야 할 것 같소.
가능한 빨리 피부과 전문의에게 진료를 받으시오." tag_color = "#B5394A" # 무섭지 않은 짙은 주홍 tag_bg = "#FBEAEC" detail_html = f"""
🔮 {tag}
{msg}
📜 점괘 풀이
AI 무당의 직관: {ai_prob*100:.1f}%
운수(문진) 가산: +{bonus*100:.1f}%
─ 최종 흉조 정도: {final*100:.1f}%
""" return label_data, detail_html # ────────────────────────────────────────── # 4. 점집 CSS — 따뜻한 한지·주홍 톤 # ────────────────────────────────────────── custom_css = """ :root { --brand: #C24536; /* 따뜻한 주홍 (테라코타) */ --brand-dark: #9E3528; --brand-soft: #E8533D; /* 더 밝은 주홍 (포인트) */ --gold: #D4A93B; /* 부드러운 금색 */ --gold-light: #E8C868; --paper: #FFF8E7; /* 한지색 */ --paper-warm: #FCEFD3; /* 더 진한 한지 */ --ink: #4A3826; --ink-sub: #7A5C3D; } .gradio-container { max-width: 430px !important; margin: 0 auto !important; padding: 0 !important; background: var(--paper) !important; min-height: 100vh !important; font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Noto Sans KR', sans-serif !important; background-image: radial-gradient(circle at 15% 8%, rgba(212,169,59,0.12) 0%, transparent 45%), radial-gradient(circle at 85% 92%, rgba(232,83,61,0.08) 0%, transparent 45%); } footer { display: none !important; } /* 한지 위 도장 느낌 헤더 */ .scr-header { background: linear-gradient(135deg, #C24536 0%, #9E3528 100%); color: white; padding: 22px 22px 20px; margin-bottom: 14px; position: relative; border-radius: 0 0 16px 16px; } .scr-header::after { content: ""; position: absolute; left: 18px; right: 18px; bottom: -2px; height: 2px; background: repeating-linear-gradient( 90deg, var(--gold) 0, var(--gold) 8px, transparent 8px, transparent 14px ); } .scr-header .scr-title { font-size: 22px; font-weight: 800; letter-spacing: 1.5px; font-family: 'Noto Serif KR', 'Nanum Myeongjo', serif; } .scr-header .scr-sub { font-size: 12.5px; opacity: 0.94; margin-top: 5px; letter-spacing: 0.3px; } /* 진행 표시 */ .progress-dots { display: flex; gap: 7px; margin: 6px 18px 14px; justify-content: center; } .progress-dot { width: 28px; height: 5px; border-radius: 3px; background: #ECDBB0; } .progress-dot.active { background: var(--gold); box-shadow: 0 0 8px rgba(212,169,59,0.5); } /* 한지 카드 */ .app-card { background: white; border-radius: 14px; padding: 16px 18px; margin: 0 18px 12px; border: 1px solid #EEDFB8; box-shadow: 0 2px 8px rgba(194,69,54,0.06); } .app-card-title { font-size: 14.5px; font-weight: 800; color: var(--brand); margin-bottom: 6px; letter-spacing: 0.5px; font-family: 'Noto Serif KR', serif; } .app-card-desc { font-size: 12.8px; color: var(--ink-sub); line-height: 1.7; } /* 디스클레이머 — 부적 느낌 */ .disclaimer { background: #FFF3DE; color: #8B5A2B; padding: 11px 14px; border-radius: 10px; margin: 0 18px 12px; font-size: 12px; font-weight: 600; text-align: center; border: 1.5px dashed #D4A93B; } /* 홈 히어로 */ .jeomjip-hero { text-align: center; padding: 12px 24px 12px; } .mudang-svg { width: 240px; max-width: 100%; height: auto; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.08)); } .hero-title { font-size: 30px; font-weight: 900; color: var(--brand); margin: 14px 0 8px; letter-spacing: 3px; font-family: 'Noto Serif KR', 'Nanum Myeongjo', serif; } .hero-sub { font-size: 14px; color: var(--ink-sub); line-height: 1.75; margin-bottom: 4px; } .hero-quote { color: var(--brand); font-weight: 700; font-style: italic; display: inline-block; margin-top: 8px; } /* 도장 느낌 주요 버튼 */ button.primary-btn { background: var(--brand) !important; color: white !important; border: none !important; padding: 15px !important; font-size: 16px !important; font-weight: 800 !important; border-radius: 14px !important; min-height: 56px !important; margin: 0 18px 10px !important; width: calc(100% - 36px) !important; letter-spacing: 2.5px !important; box-shadow: 0 4px 0 var(--brand-dark), 0 6px 12px rgba(194,69,54,0.18) !important; font-family: 'Noto Serif KR', serif !important; transition: all 0.15s !important; } button.primary-btn:hover { background: var(--brand-soft) !important; transform: translateY(2px); box-shadow: 0 2px 0 var(--brand-dark), 0 3px 6px rgba(194,69,54,0.18) !important; } /* 종이 느낌 보조 버튼 */ button.secondary-btn { background: white !important; color: var(--brand) !important; border: 2px solid var(--brand) !important; padding: 13px !important; font-size: 14.5px !important; font-weight: 800 !important; border-radius: 14px !important; min-height: 50px !important; margin: 0 18px 10px !important; width: calc(100% - 36px) !important; letter-spacing: 1.5px !important; font-family: 'Noto Serif KR', serif !important; } button.secondary-btn:hover { background: #FFF3DE !important; } .row-buttons button { width: 100% !important; margin: 0 !important; } .row-buttons { padding: 0 18px 10px; gap: 10px !important; } label, .label, .gradio-container label { color: var(--ink) !important; } """ # ────────────────────────────────────────── # 5. 친근한 무당 SVG — 미소 띤 표정 # ────────────────────────────────────────── MUDANG_SVG = """ """ # ────────────────────────────────────────── # 6. UI — 5개 화면 # ────────────────────────────────────────── with gr.Blocks(css=custom_css, title="우리 동네 점집", theme=gr.themes.Soft(primary_hue="red")) as demo: # =============== 화면 1: 홈 =============== with gr.Column(visible=True) as home_screen: gr.HTML(f"""
우리 동네 점집
그로센딕 · AI 무당이 봐주는 점(點) 점(占)
⚠️ 교육용 도구 · 의료 진단 대체 불가
{MUDANG_SVG}
점 보러 오셨소
"어서 오시오, 손님.
그 점, 무당이 봐드리리다."
— AI 무당 5명이 합심하여 점괘를 풉니다
""") start_btn = gr.Button("🔮 점견적 보기", elem_classes="primary-btn") info_from_home = gr.Button("📜 점이란?", elem_classes="secondary-btn") # =============== 화면 2: 운수 확인 (문진) =============== with gr.Column(visible=False) as questionnaire_screen: gr.HTML("""
📋 운수(運數) 확인
1 / 3 · 점괘 풀기 전 사주 묻기
— 무당의 첫 질문 —
사주를 알아야 점이 더 정확하다오. 솔직하게 답해주시오.
""") with gr.Column(elem_classes="app-card"): age = gr.Slider(0, 80, value=30, step=1, label="🎂 손님 나이는 어찌 되시오?", info="흑색종은 나이가 들수록 발생 빈도가 높아진다오") family = gr.Radio(["아니오", "예"], value="아니오", label="👨‍👩‍👧 집안에 흑색종 가진 분이 있소?", info="부모·형제 기준") mole_change = gr.Radio(["아니오", "예"], value="아니오", label="🔄 그 점이 최근 변(變)했소?", info="크기·색·모양 변화 (최근 6개월 이내)") with gr.Row(elem_classes="row-buttons"): q_back = gr.Button("← 돌아가기", elem_classes="secondary-btn") q_next = gr.Button("점 보여주오 →", elem_classes="primary-btn") # =============== 화면 3: 점 사진 =============== with gr.Column(visible=False) as photo_screen: gr.HTML("""
📸 점(點) 사진
2 / 3 · 무당에게 점을 보여주오
— 점을 가까이 비추시오 —
밝은 곳에서, 점이 화면에 가득 차게 찍어주시오. 카메라로 찍거나 갤러리에서 골라도 좋소.
""") image_input = gr.Image(sources=["upload", "webcam"], type="numpy", label="", show_label=False, height=300) with gr.Row(elem_classes="row-buttons"): p_back = gr.Button("← 돌아가기", elem_classes="secondary-btn") analyze = gr.Button("🔮 점괘 보기", elem_classes="primary-btn") # =============== 화면 4: 점괘 (결과) =============== with gr.Column(visible=False) as result_screen: gr.HTML("""
🔮 오늘의 점괘
3 / 3 · 무당의 풀이
""") with gr.Column(elem_classes="app-card"): result_label = gr.Label(label="", show_label=False, num_top_classes=2) result_detail = gr.HTML() gr.HTML('
참고용 점괘일 뿐, 진짜 진단은 피부과 전문의에게 받으시오.
') with gr.Row(elem_classes="row-buttons"): again_btn = gr.Button("🔄 다시 점치기", elem_classes="primary-btn") info_from_result = gr.Button("📜 점이란?", elem_classes="secondary-btn") # =============== 화면 5: 점이란 (정보) =============== with gr.Column(visible=False) as info_screen: gr.HTML("""
📜 점이란 무엇이오?
알아두면 좋은 의학 이야기
""") with gr.Column(elem_classes="app-card"): with gr.Accordion("🔍 흑색종이란?", open=True): gr.Markdown( "**흑색종(Melanoma)** 은 피부의 멜라노사이트(색소 세포)가 " "악성 변화한 암이오. 다른 피부암보다 발생 빈도는 낮지만 " "**전이가 빨라 가장 공격적**이라 조기 발견이 중요하다오." ) with gr.Accordion("📐 ABCDE 규칙 — 자가 검진"): gr.Markdown( "- **A**symmetry — 비대칭\n" "- **B**order — 들쭉날쭉한 경계\n" "- **C**olor — 한 점에 여러 색\n" "- **D**iameter — 6mm 이상\n" "- **E**volving — 변화\n\n" "여러 항목 해당 시 피부과 진료가 답이외다." ) with gr.Accordion("🇰🇷 한국인이 알아야 할 것"): gr.Markdown( "한국인 흑색종의 **30~50%** 는 **말단흑색점 흑색종**(손바닥·발바닥·손톱) " "형태라네. 자외선과 무관하며 단순 점처럼 보여 늦게 발견되는 경우가 많소." ) with gr.Accordion("🏥 병원에 가야 할 때"): gr.Markdown( "- 점이 변할 때 (크기·색·모양)\n" "- 점에서 출혈\n" "- 가렵거나 통증\n" "- 손·발바닥에 새로운 점\n" "- 가족 중 흑색종 환자 있음\n\n" "→ **피부과 전문의** 진료가 정답이오." ) info_back = gr.Button("← 점집으로", elem_classes="secondary-btn") # ────────────────────────────────────────── # 7. 화면 전환 로직 # ────────────────────────────────────────── all_screens = [home_screen, questionnaire_screen, photo_screen, result_screen, info_screen] def goto(screen_name): order = ["home", "questionnaire", "photo", "result", "info"] return [gr.update(visible=(s == screen_name)) for s in order] start_btn.click( fn=lambda: goto("questionnaire"), outputs=all_screens) info_from_home.click( fn=lambda: goto("info"), outputs=all_screens) q_back.click( fn=lambda: goto("home"), outputs=all_screens) q_next.click( fn=lambda: goto("photo"), outputs=all_screens) p_back.click( fn=lambda: goto("questionnaire"), outputs=all_screens) again_btn.click( fn=lambda: goto("home"), outputs=all_screens) info_from_result.click(fn=lambda: goto("info"), outputs=all_screens) info_back.click( fn=lambda: goto("home"), outputs=all_screens) # AI 점괘 → 결과 화면 def analyze_and_show(image, age_v, family_v, mole_change_v): if image is None: return goto("photo") + [None, "
먼저 점 사진을 보여주오
"] label, detail = predict_with_questionnaire(image, age_v, family_v, mole_change_v) return goto("result") + [label, detail] analyze.click( fn=analyze_and_show, inputs=[image_input, age, family, mole_change], outputs=all_screens + [result_label, result_detail], ) demo.launch()