| """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: |
| 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( |
| """ |
| <div class="hero-card"> |
| <h1 class="hero-title">AI Skin Detection Demo</h1> |
| <p class="hero-text"> |
| 上傳皮膚照片後,系統會使用本地 YOLOv8 模型進行偵測, |
| 在圖片上框出可疑區域,並整理出本次分析的摘要資訊。 |
| </p> |
| <div class="badge-row"> |
| <span class="badge">Gradio Blocks 介面</span> |
| <span class="badge">YOLOv8 本地模型</span> |
| <span class="badge">PIL 圖片輸入輸出</span> |
| </div> |
| </div> |
| """ |
| ) |
|
|
| 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( |
| """ |
| <div class="footer-note"> |
| <strong>使用提醒:</strong> |
| 此模型僅供教學展示與介面測試,不作為醫療診斷、治療建議或臨床依據。 |
| </div> |
| """ |
| ) |
|
|
| 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() |
|
|