YXskin01 / app.py
Neil67's picture
Polish UI and add demo examples
2ea366b
Raw
History Blame Contribute Delete
10.6 kB
"""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(
"""
<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()