"""Gradio demo for YOLOv8 skin condition detection. This app is designed for deployment on Hugging Face Spaces. Users upload an image, the model runs inference with a local `best.pt` checkpoint, and the app returns a PIL image annotated with detection boxes. """ from __future__ import annotations import os from collections import Counter from typing import Optional import gradio as gr from PIL import Image, ImageDraw, ImageFont from ultralytics import YOLO BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODEL_PATH = os.path.join(BASE_DIR, "best.pt") EXAMPLES_DIR = os.path.join(BASE_DIR, "examples") EXAMPLE_IMAGE_PATHS = [ os.path.join(EXAMPLES_DIR, "example_1.png"), os.path.join(EXAMPLES_DIR, "example_2.png"), ] CUSTOM_CSS = """ .gradio-container { background: radial-gradient(circle at top left, #fff4da 0%, transparent 32%), radial-gradient(circle at top right, #ffe2d6 0%, transparent 26%), linear-gradient(180deg, #fffdf8 0%, #fff7ef 100%); } .app-shell { max-width: 1180px; margin: 0 auto; } .hero-card { background: linear-gradient(135deg, #fff8ef 0%, #ffffff 65%); border: 1px solid #f3dcc5; border-radius: 28px; padding: 28px 30px; box-shadow: 0 18px 50px rgba(146, 93, 33, 0.10); margin-bottom: 18px; } .hero-title { margin: 0 0 12px 0; font-size: 2.3rem; font-weight: 800; color: #54321d; letter-spacing: -0.03em; } .hero-text { margin: 0; color: #765540; font-size: 1.02rem; line-height: 1.75; } .badge-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; } .badge { background: #fff1df; color: #8f5526; border: 1px solid #f3d3aa; border-radius: 999px; padding: 7px 14px; font-size: 0.92rem; font-weight: 700; } .panel-card { background: rgba(255, 255, 255, 0.90); border: 1px solid #f1ddc9; border-radius: 24px; padding: 18px; box-shadow: 0 12px 35px rgba(112, 71, 30, 0.08); } .summary-card { background: linear-gradient(180deg, #fff7ee 0%, #ffffff 100%); border: 1px solid #efd8c2; border-radius: 20px; padding: 10px 14px; } .footer-note { background: #5d3a24; color: #fff6ea; border-radius: 20px; padding: 16px 18px; margin-top: 20px; } .footer-note strong { color: #ffd7a8; } """ DEFAULT_SUMMARY = """### 偵測摘要 - 尚未開始分析 - 請先上傳圖片,或點選下方範例圖片 - 系統會在右側顯示框選結果與類別摘要 """ MODEL: Optional[YOLO] = None MODEL_LOAD_ERROR: Optional[str] = None try: MODEL = YOLO(MODEL_PATH) except Exception as exc: # pragma: no cover - deployment safeguard MODEL_LOAD_ERROR = str(exc) def _draw_detections(image: Image.Image, result) -> Image.Image: """Render YOLO detection boxes onto a PIL image.""" output = image.convert("RGB").copy() draw = ImageDraw.Draw(output) font = ImageFont.load_default() palette = [ (214, 94, 62), (220, 151, 49), (70, 112, 189), (91, 168, 108), (189, 87, 125), ] boxes = result.boxes if boxes is None or len(boxes) == 0: return output names = MODEL.names if MODEL is not None else {} for box in boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() cls_id = int(box.cls[0].item()) if box.cls is not None else 0 score = float(box.conf[0].item()) if box.conf is not None else 0.0 color = palette[cls_id % len(palette)] label_name = names.get(cls_id, str(cls_id)) label = f"{label_name} {score:.2f}" draw.rectangle((x1, y1, x2, y2), outline=color, width=4) left, top, right, bottom = draw.textbbox((0, 0), label, font=font) text_width = right - left text_height = bottom - top label_top = max(0, int(y1) - text_height - 10) label_bottom = label_top + text_height + 8 label_right = int(x1) + text_width + 10 draw.rectangle( (int(x1), label_top, label_right, label_bottom), fill=color, ) draw.text((int(x1) + 5, label_top + 4), label, fill="white", font=font) return output def _build_summary(result) -> str: """Build a markdown summary of detections for the UI.""" boxes = result.boxes if boxes is None or len(boxes) == 0: return """### 偵測摘要 - 本次沒有偵測到目標 - 你可以改用更清晰、近距離的皮膚照片再試一次 - 建議避免模糊、過暗或過曝的圖片 """ names = MODEL.names if MODEL is not None else {} class_ids = [int(box.cls[0].item()) for box in boxes] scores = [float(box.conf[0].item()) for box in boxes] counts = Counter(class_ids) lines = [ "### 偵測摘要", f"- 偵測到 **{len(boxes)}** 個目標", f"- 最高信心分數:**{max(scores):.2f}**", "- 類別統計:", ] for class_id, count in counts.items(): class_name = names.get(class_id, str(class_id)) lines.append(f" - {class_name}:{count} 個") lines.extend( [ "- 右側圖片已顯示框選結果", "- 此結果僅供教學展示,不作為醫療判斷依據", ] ) return "\n".join(lines) def _run_detection(image: Image.Image) -> tuple[Image.Image, str]: """Run YOLO and return both the annotated image and summary.""" if image is None: raise gr.Error("請先上傳一張圖片。") if MODEL is None: error_message = MODEL_LOAD_ERROR or "Unknown model loading error." raise gr.Error( "無法載入本地模型 `best.pt`。" f" 請確認模型檔案存在。詳細訊息:{error_message}" ) results = MODEL.predict(image, conf=0.25, imgsz=640, verbose=False) if not results: return image.convert("RGB"), DEFAULT_SUMMARY result = results[0] annotated = _draw_detections(image, result) summary = _build_summary(result) return annotated, summary def predict(image: Image.Image) -> Image.Image: """Run YOLOv8 prediction and return only the annotated PIL image.""" annotated, _ = _run_detection(image) return annotated def predict_with_summary(image: Image.Image) -> tuple[Image.Image, str]: """Wrapper used by the Gradio button to show both image and summary.""" return _run_detection(image) def clear_outputs(): """Reset all interactive components.""" return None, None, DEFAULT_SUMMARY def build_demo() -> gr.Blocks: """Create the Gradio Blocks UI.""" theme = gr.themes.Soft( primary_hue="amber", secondary_hue="orange", neutral_hue="stone", ) with gr.Blocks( title="AI Skin Detection Demo", theme=theme, css=CUSTOM_CSS, ) as demo: with gr.Column(elem_classes="app-shell"): gr.HTML( """

AI Skin Detection Demo

上傳皮膚照片後,系統會使用本地 YOLOv8 模型進行偵測, 在圖片上框出可疑區域,並整理出本次分析的摘要資訊。

Gradio Blocks 介面 YOLOv8 本地模型 PIL 圖片輸入輸出
""" ) with gr.Row(equal_height=True): with gr.Column(elem_classes="panel-card", scale=1): input_image = gr.Image( label="上傳皮膚圖片", type="pil", sources=["upload"], height=420, ) with gr.Row(): detect_button = gr.Button("開始偵測", variant="primary", scale=3) clear_button = gr.Button("清除", scale=2) with gr.Column(elem_classes="panel-card", scale=1): gr.Markdown("### 分析結果") output_image = gr.Image( label="偵測結果", type="pil", height=420, interactive=False, ) summary_markdown = gr.Markdown( value=DEFAULT_SUMMARY, elem_classes="summary-card", ) with gr.Accordion("使用建議", open=False): gr.Markdown( """ - 建議上傳清楚、光線均勻的近距離皮膚照片 - 若圖片模糊、陰影過重或主體太小,結果可能不穩定 - 若沒有偵測到目標,可改用更近、更清楚的圖片重新測試 """ ) with gr.Group(elem_classes="panel-card"): gr.Examples( examples=EXAMPLE_IMAGE_PATHS, inputs=input_image, outputs=[output_image, summary_markdown], fn=predict_with_summary, cache_examples=False, label="快速試用範例圖片", ) detect_button.click( fn=predict_with_summary, inputs=input_image, outputs=[output_image, summary_markdown], ) clear_button.click( fn=clear_outputs, inputs=None, outputs=[input_image, output_image, summary_markdown], ) gr.HTML( """ """ ) return demo demo = build_demo() def _is_hugging_face_spaces() -> bool: """Detect whether the app is running inside Hugging Face Spaces.""" return bool(os.getenv("SPACE_ID")) if __name__ == "__main__": if _is_hugging_face_spaces(): demo.launch(server_name="0.0.0.0") else: demo.launch()