Update app.py
Browse files
app.py
CHANGED
|
@@ -27,13 +27,32 @@ from transformers import pipeline
|
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
# Config — override any of these via Space "Variables and secrets"
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
-
IMAGE_MODEL_ID = os.environ.get("IMAGE_MODEL_ID", "Anwarkh1/Skin_Cancer-Image_Classification")
|
| 31 |
# Hugging Face retired the old api-inference.huggingface.co Serverless API in
|
| 32 |
# favor of the "Inference Providers" router (OpenAI-compatible). Any chat
|
| 33 |
# model listed at https://huggingface.co/models?inference_provider=hf-inference
|
| 34 |
# works here.
|
| 35 |
-
CHAT_MODEL_ID = os.environ.get("CHAT_MODEL_ID", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
HF_TOKEN = os.environ.get("HF_TOKEN") # add as a Space SECRET to enable the chatbot
|
|
|
|
| 37 |
ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 38 |
|
| 39 |
PAGE_DISCLAIMER = (
|
|
@@ -46,7 +65,7 @@ PAGE_DISCLAIMER = (
|
|
| 46 |
# where melanoma is its own clean label). Useful because broader taxonomies like
|
| 47 |
# DermNet lump "Melanoma Skin Cancer, Nevi and Moles" into one label — this lets a
|
| 48 |
# mole/nevus-flagged image get a second, more specific opinion. Leave unset to disable.
|
| 49 |
-
LESION_MODEL_ID = os.environ.get("LESION_MODEL_ID", "")
|
| 50 |
|
| 51 |
# ---------------------------------------------------------------------------
|
| 52 |
# Urgency tiering — keyword-based rather than an exact-match label dict, so it
|
|
@@ -69,9 +88,24 @@ PROMPT_DOCTOR_KEYWORDS = [
|
|
| 69 |
# benign tumors, common moles, etc.) where general skin-care guidance fits.
|
| 70 |
|
| 71 |
SEVERITY = {
|
| 72 |
-
"urgent": {
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
|
|
@@ -87,16 +121,18 @@ def classify_tier(label):
|
|
| 87 |
return "general_care"
|
| 88 |
|
| 89 |
|
| 90 |
-
def severity_line(label):
|
| 91 |
tier = classify_tier(label)
|
| 92 |
s = SEVERITY[tier]
|
|
|
|
|
|
|
| 93 |
return f"{s['emoji']} **Severity: {s['label']}** — {s['note']}"
|
| 94 |
|
| 95 |
|
| 96 |
# Friendly one-line descriptions for labels we recognize exactly (both the
|
| 97 |
# original 7-class HAM10000 set and common DermNet-style labels). Purely
|
| 98 |
# cosmetic — if a label isn't here, the tier-based guidance still works fine
|
| 99 |
-
# without a description.
|
| 100 |
KNOWN_DESCRIPTIONS = {
|
| 101 |
"actinic keratoses": "A rough, scaly patch caused by sun damage.",
|
| 102 |
"basal cell carcinoma": "The most common type of skin cancer.",
|
|
@@ -106,9 +142,21 @@ KNOWN_DESCRIPTIONS = {
|
|
| 106 |
"melanocytic nevi": "Ordinary moles.",
|
| 107 |
"vascular lesions": "Blood-vessel-related marks such as angiomas.",
|
| 108 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
SYSTEM_PROMPT = """You are a warm, knowledgeable skin-health triage assistant. You are not a doctor and cannot diagnose anyone with certainty — but your job is to be genuinely useful and specific, not to hide behind vague hedging or constant disclaimers.
|
| 111 |
|
|
|
|
|
|
|
|
|
|
| 112 |
CONVERSATION STYLE — GO DEEPER BEFORE ASSESSING
|
| 113 |
Have a real back-and-forth, one or two questions at a time. Aim to gather a fuller picture than a quick glance would give — typically 5-8 exchanges before your assessment, more if the picture is still unclear, fewer only if the user clearly just wants a fast read or red flags are already obvious. Useful ground to cover (skip anything already answered or clearly irrelevant):
|
| 114 |
- Onset: how long they've had it, how it started
|
|
@@ -167,39 +215,107 @@ def get_lesion_classifier():
|
|
| 167 |
|
| 168 |
|
| 169 |
def chat_completion(messages, max_tokens=500):
|
| 170 |
-
"""Call the Hugging Face Inference Providers router directly (OpenAI-compatible).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
if not HF_TOKEN:
|
| 172 |
return None
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
try:
|
| 184 |
detail = resp.json()
|
|
|
|
| 185 |
except Exception:
|
| 186 |
detail = resp.text
|
| 187 |
-
|
| 188 |
-
data = resp.json()
|
| 189 |
-
return data["choices"][0]["message"]["content"]
|
| 190 |
|
|
|
|
| 191 |
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
"""Run the primary classifier and return (preds, formatted_lines). If the top
|
| 194 |
prediction looks like a mixed/ambiguous pigmented-lesion label and a second
|
| 195 |
lesion-specific model is configured, also run that for a second opinion."""
|
| 196 |
clf = get_classifier()
|
| 197 |
preds = clf(image, top_k=5)
|
|
|
|
| 198 |
lines = []
|
| 199 |
for p in preds:
|
| 200 |
label = p["label"]
|
| 201 |
score = p["score"] * 100
|
| 202 |
-
desc =
|
| 203 |
desc_txt = f" \n _{desc}_" if desc else ""
|
| 204 |
lines.append(f"- **{label}** — {score:.1f}%{desc_txt}")
|
| 205 |
|
|
@@ -208,7 +324,12 @@ def classify_image(image):
|
|
| 208 |
lesion_clf = get_lesion_classifier()
|
| 209 |
if lesion_clf is not None:
|
| 210 |
lesion_preds = lesion_clf(image, top_k=3)
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
for p in lesion_preds:
|
| 213 |
lines.append(f"- **{p['label']}** — {p['score']*100:.1f}%")
|
| 214 |
preds = preds + [{"label": f"[lesion-model] {p['label']}", "score": p["score"]} for p in lesion_preds]
|
|
@@ -237,20 +358,26 @@ def build_classifier_context(preds):
|
|
| 237 |
# Image diagnosis tab (single-shot: upload -> classify -> explain)
|
| 238 |
# ---------------------------------------------------------------------------
|
| 239 |
@spaces.GPU
|
| 240 |
-
def diagnose_image(image):
|
| 241 |
if image is None:
|
| 242 |
-
return "Please upload an image or pick one of the test images first
|
| 243 |
|
| 244 |
try:
|
| 245 |
-
preds, lines_list = classify_image(image)
|
| 246 |
except Exception as e:
|
| 247 |
return f"**Model error:** {e}\n\nMake sure `IMAGE_MODEL_ID` is a valid image-classification model."
|
| 248 |
|
| 249 |
-
|
| 250 |
-
lines
|
|
|
|
| 251 |
|
| 252 |
if HF_TOKEN:
|
| 253 |
context = build_classifier_context(preds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
prompt = (
|
| 255 |
f"{context}\n\n"
|
| 256 |
"The user just uploaded a single image with no conversation yet. Give a short "
|
|
@@ -259,7 +386,7 @@ def diagnose_image(image):
|
|
| 259 |
"mention that an in-person exam is what actually confirms things. Note that without "
|
| 260 |
"any conversation history you have less context than usual — you can still give your "
|
| 261 |
"best read, but you may want to suggest the Chat Assistant for a fuller picture if the "
|
| 262 |
-
"case seems ambiguous. Keep it to 5-7 sentences."
|
| 263 |
)
|
| 264 |
try:
|
| 265 |
explanation = chat_completion(
|
|
@@ -267,11 +394,14 @@ def diagnose_image(image):
|
|
| 267 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 268 |
{"role": "user", "content": prompt},
|
| 269 |
],
|
| 270 |
-
max_tokens=
|
| 271 |
)
|
| 272 |
-
|
|
|
|
| 273 |
except Exception as e:
|
| 274 |
-
|
|
|
|
|
|
|
| 275 |
else:
|
| 276 |
lines.append(
|
| 277 |
"\n_Add an `HF_TOKEN` secret to this Space to also get a plain-language "
|
|
@@ -280,7 +410,8 @@ def diagnose_image(image):
|
|
| 280 |
|
| 281 |
lines.append(
|
| 282 |
"\n---\n_Tip: use the **Chat Assistant** tab for a fuller triage conversation — "
|
| 283 |
-
"it can ask follow-up questions and give a more tailored read.
|
|
|
|
| 284 |
)
|
| 285 |
return "\n".join(lines)
|
| 286 |
|
|
@@ -326,6 +457,19 @@ def _stage_note(user_turn_count):
|
|
| 326 |
)
|
| 327 |
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
def chat_respond(message, history):
|
| 330 |
if not HF_TOKEN:
|
| 331 |
return (
|
|
@@ -334,6 +478,7 @@ def chat_respond(message, history):
|
|
| 334 |
)
|
| 335 |
|
| 336 |
text, image_path = _extract_text_and_image(message)
|
|
|
|
| 337 |
|
| 338 |
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 339 |
for turn in history:
|
|
@@ -367,7 +512,13 @@ def chat_respond(message, history):
|
|
| 367 |
try:
|
| 368 |
return chat_completion(messages, max_tokens=500)
|
| 369 |
except Exception as e:
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
|
| 373 |
# ---------------------------------------------------------------------------
|
|
@@ -479,6 +630,41 @@ about what it isn't: a validated medical device.
|
|
| 479 |
done. This should inform, not replace, professional care.
|
| 480 |
|
| 481 |
{PAGE_DISCLAIMER}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
"""
|
| 483 |
|
| 484 |
|
|
@@ -494,27 +680,31 @@ if os.path.isdir(EXAMPLES_DIR):
|
|
| 494 |
if f.lower().endswith((".jpg", ".jpeg", ".png"))
|
| 495 |
]
|
| 496 |
|
| 497 |
-
with gr.Blocks(title="Skin Disease Detection — Demo") as demo:
|
| 498 |
-
gr.Markdown("# 🩺 Skin Disease Detection")
|
| 499 |
-
gr.Markdown(f"_{PAGE_DISCLAIMER}_")
|
| 500 |
|
| 501 |
with gr.Tab("📷 Quick Image Check"):
|
| 502 |
with gr.Row():
|
| 503 |
with gr.Column():
|
| 504 |
-
img_in = gr.Image(type="pil", label="Upload a skin image")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
if example_images:
|
| 506 |
-
gr.Examples(examples=example_images, inputs=img_in, label="Or try a test image")
|
| 507 |
else:
|
| 508 |
gr.Markdown(
|
| 509 |
"_No bundled test images found. Run `scripts/download_examples.py` "
|
| 510 |
"before deploying, or just upload your own image._"
|
| 511 |
)
|
| 512 |
-
analyze_btn = gr.Button("Analyze image", variant="primary")
|
| 513 |
with gr.Column():
|
| 514 |
result_md = gr.Markdown()
|
| 515 |
-
analyze_btn.click(diagnose_image, inputs=img_in, outputs=result_md)
|
| 516 |
|
| 517 |
-
with gr.Tab("💬 Chat Assistant"):
|
| 518 |
gr.ChatInterface(
|
| 519 |
fn=chat_respond,
|
| 520 |
multimodal=True,
|
|
@@ -522,11 +712,15 @@ with gr.Blocks(title="Skin Disease Detection — Demo") as demo:
|
|
| 522 |
"Talk through what you're noticing, and optionally attach a photo (📎). "
|
| 523 |
"The assistant will ask several follow-up questions before giving a preliminary "
|
| 524 |
"read with a Low/Medium/High severity call — the more you share, the more "
|
| 525 |
-
"targeted that read can be."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
),
|
| 527 |
)
|
| 528 |
|
| 529 |
-
with gr.Tab("📊 Model Performance"):
|
| 530 |
performance_reports = load_performance_reports()
|
| 531 |
if not performance_reports:
|
| 532 |
gr.Markdown(
|
|
@@ -534,21 +728,25 @@ with gr.Blocks(title="Skin Disease Detection — Demo") as demo:
|
|
| 534 |
"Run either fine-tuning notebook in this repo, then download the "
|
| 535 |
"`*_metrics.json` and `*_confusion_matrix.png` files it produces and place "
|
| 536 |
"them in this Space's `assets/` folder. This tab picks them up automatically "
|
| 537 |
-
"on the next restart."
|
|
|
|
|
|
|
|
|
|
| 538 |
)
|
| 539 |
else:
|
| 540 |
for report in performance_reports:
|
| 541 |
gr.Markdown(format_performance_markdown(report))
|
| 542 |
if report["image_path"]:
|
| 543 |
-
gr.Image(value=report["image_path"], label="Confusion matrix (test set)", show_label=True)
|
| 544 |
|
| 545 |
-
with gr.Tab("ℹ️ About"):
|
| 546 |
gr.Markdown(ABOUT_MARKDOWN)
|
| 547 |
|
| 548 |
gr.Markdown(
|
| 549 |
"---\nBuilt with 🤗 Transformers + Gradio. "
|
| 550 |
-
f"Image model: `{IMAGE_MODEL_ID}` · Chat model: `{CHAT_MODEL_ID}`"
|
|
|
|
| 551 |
)
|
| 552 |
|
| 553 |
if __name__ == "__main__":
|
| 554 |
-
demo.launch()
|
|
|
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
# Config — override any of these via Space "Variables and secrets"
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
+
IMAGE_MODEL_ID = os.environ.get("IMAGE_MODEL_ID", "Anwarkh1/Skin_Cancer-Image_Classification").strip()
|
| 31 |
# Hugging Face retired the old api-inference.huggingface.co Serverless API in
|
| 32 |
# favor of the "Inference Providers" router (OpenAI-compatible). Any chat
|
| 33 |
# model listed at https://huggingface.co/models?inference_provider=hf-inference
|
| 34 |
# works here.
|
| 35 |
+
CHAT_MODEL_ID = os.environ.get("CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct").strip()
|
| 36 |
+
# If CHAT_MODEL_ID becomes unavailable (providers rotate their catalogs over
|
| 37 |
+
# time -- see chat_completion() below), these are tried next, in order.
|
| 38 |
+
# Override via a comma-separated CHAT_MODEL_FALLBACKS variable if needed.
|
| 39 |
+
# (mistralai/Mistral-7B-Instruct-v0.3 was tried and confirmed NOT to work via
|
| 40 |
+
# the router -- "not a chat model" -- so it's deliberately left out here.)
|
| 41 |
+
CHAT_MODEL_FALLBACKS = [
|
| 42 |
+
m.strip()
|
| 43 |
+
for m in os.environ.get(
|
| 44 |
+
"CHAT_MODEL_FALLBACKS",
|
| 45 |
+
"openai/gpt-oss-120b,"
|
| 46 |
+
"openai/gpt-oss-20b,"
|
| 47 |
+
"deepseek-ai/DeepSeek-V3-0324,"
|
| 48 |
+
"meta-llama/Llama-3.1-8B-Instruct,"
|
| 49 |
+
"meta-llama/Llama-3.3-70B-Instruct,"
|
| 50 |
+
"Qwen/Qwen2.5-72B-Instruct",
|
| 51 |
+
).split(",")
|
| 52 |
+
if m.strip()
|
| 53 |
+
]
|
| 54 |
HF_TOKEN = os.environ.get("HF_TOKEN") # add as a Space SECRET to enable the chatbot
|
| 55 |
+
HF_TOKEN = HF_TOKEN.strip() if HF_TOKEN else HF_TOKEN
|
| 56 |
ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 57 |
|
| 58 |
PAGE_DISCLAIMER = (
|
|
|
|
| 65 |
# where melanoma is its own clean label). Useful because broader taxonomies like
|
| 66 |
# DermNet lump "Melanoma Skin Cancer, Nevi and Moles" into one label — this lets a
|
| 67 |
# mole/nevus-flagged image get a second, more specific opinion. Leave unset to disable.
|
| 68 |
+
LESION_MODEL_ID = os.environ.get("LESION_MODEL_ID", "").strip()
|
| 69 |
|
| 70 |
# ---------------------------------------------------------------------------
|
| 71 |
# Urgency tiering — keyword-based rather than an exact-match label dict, so it
|
|
|
|
| 88 |
# benign tumors, common moles, etc.) where general skin-care guidance fits.
|
| 89 |
|
| 90 |
SEVERITY = {
|
| 91 |
+
"urgent": {
|
| 92 |
+
"label": "High", "label_ja": "高",
|
| 93 |
+
"emoji": "🔴",
|
| 94 |
+
"note": "recommend seeing a dermatologist/doctor soon",
|
| 95 |
+
"note_ja": "早めに皮膚科・医師の受診をおすすめします",
|
| 96 |
+
},
|
| 97 |
+
"prompt_doctor_visit": {
|
| 98 |
+
"label": "Medium", "label_ja": "中",
|
| 99 |
+
"emoji": "🟡",
|
| 100 |
+
"note": "needs a proper diagnosis/prescription, not an emergency",
|
| 101 |
+
"note_ja": "緊急ではありませんが、正式な診断・処方が必要です",
|
| 102 |
+
},
|
| 103 |
+
"general_care": {
|
| 104 |
+
"label": "Low", "label_ja": "低",
|
| 105 |
+
"emoji": "🟢",
|
| 106 |
+
"note": "typically manageable with general skin care",
|
| 107 |
+
"note_ja": "一般的なスキンケアで対応できることが多いです",
|
| 108 |
+
},
|
| 109 |
}
|
| 110 |
|
| 111 |
|
|
|
|
| 121 |
return "general_care"
|
| 122 |
|
| 123 |
|
| 124 |
+
def severity_line(label, language="English"):
|
| 125 |
tier = classify_tier(label)
|
| 126 |
s = SEVERITY[tier]
|
| 127 |
+
if language == "日本語":
|
| 128 |
+
return f"{s['emoji']} **重症度:{s['label_ja']}({s['label']})** — {s['note_ja']}"
|
| 129 |
return f"{s['emoji']} **Severity: {s['label']}** — {s['note']}"
|
| 130 |
|
| 131 |
|
| 132 |
# Friendly one-line descriptions for labels we recognize exactly (both the
|
| 133 |
# original 7-class HAM10000 set and common DermNet-style labels). Purely
|
| 134 |
# cosmetic — if a label isn't here, the tier-based guidance still works fine
|
| 135 |
+
# without a description. English and Japanese pairs, keyed the same.
|
| 136 |
KNOWN_DESCRIPTIONS = {
|
| 137 |
"actinic keratoses": "A rough, scaly patch caused by sun damage.",
|
| 138 |
"basal cell carcinoma": "The most common type of skin cancer.",
|
|
|
|
| 142 |
"melanocytic nevi": "Ordinary moles.",
|
| 143 |
"vascular lesions": "Blood-vessel-related marks such as angiomas.",
|
| 144 |
}
|
| 145 |
+
KNOWN_DESCRIPTIONS_JA = {
|
| 146 |
+
"actinic keratoses": "日光による損傷が原因の、ざらざらした鱗屑状の斑点です。",
|
| 147 |
+
"basal cell carcinoma": "最も一般的な皮膚がんの一種です。",
|
| 148 |
+
"melanoma": "最も深刻な、一般的な皮膚がんの一種です。",
|
| 149 |
+
"benign keratosis-like lesions": "脂漏性角化症や日光黒子など、良性の増殖です。",
|
| 150 |
+
"dermatofibroma": "主に脚にできる、一般的な良性のしこりです。",
|
| 151 |
+
"melanocytic nevi": "一般的なほくろです。",
|
| 152 |
+
"vascular lesions": "血管腫など、血管に関連する良性のマークです。",
|
| 153 |
+
}
|
| 154 |
|
| 155 |
SYSTEM_PROMPT = """You are a warm, knowledgeable skin-health triage assistant. You are not a doctor and cannot diagnose anyone with certainty — but your job is to be genuinely useful and specific, not to hide behind vague hedging or constant disclaimers.
|
| 156 |
|
| 157 |
+
LANGUAGE
|
| 158 |
+
You are fully fluent in both English and Japanese. Always respond in the same language the user's most recent message is written in — if they write in Japanese (日本語), respond entirely in natural, fluent Japanese; if they write in English, respond in English. If a conversation switches languages mid-way, follow the switch. Keep medical/technical terms accurate in whichever language you're using rather than leaving them untranslated.
|
| 159 |
+
|
| 160 |
CONVERSATION STYLE — GO DEEPER BEFORE ASSESSING
|
| 161 |
Have a real back-and-forth, one or two questions at a time. Aim to gather a fuller picture than a quick glance would give — typically 5-8 exchanges before your assessment, more if the picture is still unclear, fewer only if the user clearly just wants a fast read or red flags are already obvious. Useful ground to cover (skip anything already answered or clearly irrelevant):
|
| 162 |
- Onset: how long they've had it, how it started
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
def chat_completion(messages, max_tokens=500):
|
| 218 |
+
"""Call the Hugging Face Inference Providers router directly (OpenAI-compatible).
|
| 219 |
+
|
| 220 |
+
Free-tier model availability on HF's router shifts over time -- providers
|
| 221 |
+
add/drop models from their own catalogs independently of anything in this
|
| 222 |
+
app. Rather than hard-failing the moment the configured model disappears,
|
| 223 |
+
this tries a short list of fallback candidates in order and only raises
|
| 224 |
+
once all of them have failed.
|
| 225 |
+
"""
|
| 226 |
if not HF_TOKEN:
|
| 227 |
return None
|
| 228 |
+
|
| 229 |
+
candidates = [CHAT_MODEL_ID] + [m for m in CHAT_MODEL_FALLBACKS if m != CHAT_MODEL_ID]
|
| 230 |
+
last_error = None
|
| 231 |
+
|
| 232 |
+
for model_id in candidates:
|
| 233 |
+
resp = requests.post(
|
| 234 |
+
ROUTER_URL,
|
| 235 |
+
headers={"Authorization": f"Bearer {HF_TOKEN}"},
|
| 236 |
+
json={"model": model_id, "messages": messages, "max_tokens": max_tokens},
|
| 237 |
+
timeout=60,
|
| 238 |
+
)
|
| 239 |
+
if resp.ok:
|
| 240 |
+
data = resp.json()
|
| 241 |
+
return data["choices"][0]["message"]["content"]
|
| 242 |
+
|
| 243 |
try:
|
| 244 |
detail = resp.json()
|
| 245 |
+
code = detail.get("error", {}).get("code", "")
|
| 246 |
except Exception:
|
| 247 |
detail = resp.text
|
| 248 |
+
code = ""
|
|
|
|
|
|
|
| 249 |
|
| 250 |
+
last_error = f"{resp.status_code} error for model '{model_id}': {detail}"
|
| 251 |
|
| 252 |
+
# Only fall through to the next candidate for availability-type errors.
|
| 253 |
+
# Anything else (bad payload, auth, rate limit) will fail the same way
|
| 254 |
+
# for every model, so surface it immediately instead of masking it.
|
| 255 |
+
if code not in ("model_not_supported", "model_not_found"):
|
| 256 |
+
raise RuntimeError(last_error)
|
| 257 |
+
|
| 258 |
+
raise RuntimeError(
|
| 259 |
+
f"All {len(candidates)} configured chat models are currently unavailable. Last error: {last_error}"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def local_fallback_response(preds=None, language="English"):
|
| 264 |
+
"""A response that makes zero external API calls -- this is the true final
|
| 265 |
+
fallback, used only when every configured chat model in CHAT_MODEL_FALLBACKS
|
| 266 |
+
has failed. No remote model can ever be *guaranteed* available (that's the
|
| 267 |
+
whole reason the fallback list exists), but this can be: it only depends on
|
| 268 |
+
the image classifier, which runs locally on this Space's own hardware, not
|
| 269 |
+
on HF's shared router. If `preds` (classifier output) is available, this
|
| 270 |
+
still gives a real, accurate severity read -- just without the LLM's
|
| 271 |
+
natural-language elaboration.
|
| 272 |
+
"""
|
| 273 |
+
if preds:
|
| 274 |
+
top = preds[0]
|
| 275 |
+
sev = severity_line(top["label"], language)
|
| 276 |
+
if language == "日本語":
|
| 277 |
+
return (
|
| 278 |
+
"⚠️ 現在、AIチャットモデルに接続できません(設定されて��るすべてのモデルが"
|
| 279 |
+
"利用できませんでした)。分類器の結果のみに基づく簡易情報です:\n\n"
|
| 280 |
+
f"**最も可能性の高い分類:** {top['label']}({top['score']*100:.1f}%)\n\n"
|
| 281 |
+
f"{sev}\n\n"
|
| 282 |
+
"詳しい説明は現在提供できません。しばらくしてから再度お試しいただくか、"
|
| 283 |
+
"重症度が「高」の場合や不安がある場合は医師にご相談ください。"
|
| 284 |
+
)
|
| 285 |
+
return (
|
| 286 |
+
"⚠️ I can't reach any AI chat model right now (every configured model is "
|
| 287 |
+
"currently unavailable). Here's what I can still tell you from the image "
|
| 288 |
+
f"classifier alone:\n\n**Most likely category:** {top['label']} ({top['score']*100:.1f}%)\n\n"
|
| 289 |
+
f"{sev}\n\n"
|
| 290 |
+
"A fuller explanation isn't available right now. Please try again shortly, "
|
| 291 |
+
"or if the severity above is High or you're concerned, see a doctor directly."
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
if language == "日本語":
|
| 295 |
+
return (
|
| 296 |
+
"⚠️ 現在、AIチャットモデルに接続できません(設定されているすべてのモデルが"
|
| 297 |
+
"利用できませんでした)。しばらくしてから再度お試しください。"
|
| 298 |
+
"急を要する症状がある場合は、AIの応答を待たずに医師の診察を受けてください。"
|
| 299 |
+
)
|
| 300 |
+
return (
|
| 301 |
+
"⚠️ I can't reach any AI chat model right now (every configured model is "
|
| 302 |
+
"currently unavailable). Please try again in a little while. If you're dealing "
|
| 303 |
+
"with an urgent symptom, don't wait on this — see a doctor directly."
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def classify_image(image, language="English"):
|
| 308 |
"""Run the primary classifier and return (preds, formatted_lines). If the top
|
| 309 |
prediction looks like a mixed/ambiguous pigmented-lesion label and a second
|
| 310 |
lesion-specific model is configured, also run that for a second opinion."""
|
| 311 |
clf = get_classifier()
|
| 312 |
preds = clf(image, top_k=5)
|
| 313 |
+
desc_dict = KNOWN_DESCRIPTIONS_JA if language == "日本語" else KNOWN_DESCRIPTIONS
|
| 314 |
lines = []
|
| 315 |
for p in preds:
|
| 316 |
label = p["label"]
|
| 317 |
score = p["score"] * 100
|
| 318 |
+
desc = desc_dict.get(label.lower(), "")
|
| 319 |
desc_txt = f" \n _{desc}_" if desc else ""
|
| 320 |
lines.append(f"- **{label}** — {score:.1f}%{desc_txt}")
|
| 321 |
|
|
|
|
| 324 |
lesion_clf = get_lesion_classifier()
|
| 325 |
if lesion_clf is not None:
|
| 326 |
lesion_preds = lesion_clf(image, top_k=3)
|
| 327 |
+
second_opinion_label = (
|
| 328 |
+
"第二意見(このモデルではメラノーマが独立したラベルです):"
|
| 329 |
+
if language == "日本語"
|
| 330 |
+
else "Second opinion from a lesion-specific model (melanoma is a distinct label here):"
|
| 331 |
+
)
|
| 332 |
+
lines.append(f"\n_{second_opinion_label}_")
|
| 333 |
for p in lesion_preds:
|
| 334 |
lines.append(f"- **{p['label']}** — {p['score']*100:.1f}%")
|
| 335 |
preds = preds + [{"label": f"[lesion-model] {p['label']}", "score": p["score"]} for p in lesion_preds]
|
|
|
|
| 358 |
# Image diagnosis tab (single-shot: upload -> classify -> explain)
|
| 359 |
# ---------------------------------------------------------------------------
|
| 360 |
@spaces.GPU
|
| 361 |
+
def diagnose_image(image, language="English"):
|
| 362 |
if image is None:
|
| 363 |
+
return "Please upload an image or pick one of the test images first。 画像をアップロードするか、テスト画像を選択してください。"
|
| 364 |
|
| 365 |
try:
|
| 366 |
+
preds, lines_list = classify_image(image, language)
|
| 367 |
except Exception as e:
|
| 368 |
return f"**Model error:** {e}\n\nMake sure `IMAGE_MODEL_ID` is a valid image-classification model."
|
| 369 |
|
| 370 |
+
header = "### 🔬 分類結果\n" if language == "日本語" else "### 🔬 Classifier prediction\n"
|
| 371 |
+
lines = [header] + lines_list
|
| 372 |
+
lines.append(f"\n{severity_line(preds[0]['label'], language)}")
|
| 373 |
|
| 374 |
if HF_TOKEN:
|
| 375 |
context = build_classifier_context(preds)
|
| 376 |
+
lang_instruction = (
|
| 377 |
+
"Respond entirely in natural, fluent Japanese (日本語)."
|
| 378 |
+
if language == "日本語"
|
| 379 |
+
else "Respond entirely in English."
|
| 380 |
+
)
|
| 381 |
prompt = (
|
| 382 |
f"{context}\n\n"
|
| 383 |
"The user just uploaded a single image with no conversation yet. Give a short "
|
|
|
|
| 386 |
"mention that an in-person exam is what actually confirms things. Note that without "
|
| 387 |
"any conversation history you have less context than usual — you can still give your "
|
| 388 |
"best read, but you may want to suggest the Chat Assistant for a fuller picture if the "
|
| 389 |
+
f"case seems ambiguous. Keep it to 5-7 sentences. {lang_instruction}"
|
| 390 |
)
|
| 391 |
try:
|
| 392 |
explanation = chat_completion(
|
|
|
|
| 394 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 395 |
{"role": "user", "content": prompt},
|
| 396 |
],
|
| 397 |
+
max_tokens=350,
|
| 398 |
)
|
| 399 |
+
header = "🤖 Assessment" if language == "English" else "🤖 診断アシスタントの見解"
|
| 400 |
+
lines.append(f"\n### {header}\n{explanation}")
|
| 401 |
except Exception as e:
|
| 402 |
+
fallback_header = "🤖 Assessment (offline mode)" if language == "English" else "🤖 診断アシスタントの見解(オフラインモード)"
|
| 403 |
+
lines.append(f"\n### {fallback_header}\n{local_fallback_response(preds, language)}")
|
| 404 |
+
lines.append(f"\n<sub>_Debug detail: {e}_</sub>")
|
| 405 |
else:
|
| 406 |
lines.append(
|
| 407 |
"\n_Add an `HF_TOKEN` secret to this Space to also get a plain-language "
|
|
|
|
| 410 |
|
| 411 |
lines.append(
|
| 412 |
"\n---\n_Tip: use the **Chat Assistant** tab for a fuller triage conversation — "
|
| 413 |
+
"it can ask follow-up questions and give a more tailored read. "
|
| 414 |
+
"(より詳しい問診は「Chat Assistant」タブをご利用ください)_"
|
| 415 |
)
|
| 416 |
return "\n".join(lines)
|
| 417 |
|
|
|
|
| 457 |
)
|
| 458 |
|
| 459 |
|
| 460 |
+
def _detect_language(text):
|
| 461 |
+
"""Cheap heuristic for the local fallback only -- checks for Hiragana/Katakana/
|
| 462 |
+
common Kanji characters. The main chat flow doesn't need this (the LLM itself
|
| 463 |
+
handles language-mirroring), but the local fallback has no LLM to ask."""
|
| 464 |
+
if not text:
|
| 465 |
+
return "English"
|
| 466 |
+
for ch in text:
|
| 467 |
+
code = ord(ch)
|
| 468 |
+
if (0x3040 <= code <= 0x30FF) or (0x4E00 <= code <= 0x9FFF):
|
| 469 |
+
return "日本語"
|
| 470 |
+
return "English"
|
| 471 |
+
|
| 472 |
+
|
| 473 |
def chat_respond(message, history):
|
| 474 |
if not HF_TOKEN:
|
| 475 |
return (
|
|
|
|
| 478 |
)
|
| 479 |
|
| 480 |
text, image_path = _extract_text_and_image(message)
|
| 481 |
+
preds = None # hoisted so it's available to the local fallback below, if an image was shared
|
| 482 |
|
| 483 |
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 484 |
for turn in history:
|
|
|
|
| 512 |
try:
|
| 513 |
return chat_completion(messages, max_tokens=500)
|
| 514 |
except Exception as e:
|
| 515 |
+
detected_lang = _detect_language(text)
|
| 516 |
+
fallback = local_fallback_response(preds, detected_lang)
|
| 517 |
+
note = (
|
| 518 |
+
f"\n\n<sub>_Debug detail: {e}_</sub>" if detected_lang == "English"
|
| 519 |
+
else f"\n\n<sub>_詳細: {e}_</sub>"
|
| 520 |
+
)
|
| 521 |
+
return fallback + note
|
| 522 |
|
| 523 |
|
| 524 |
# ---------------------------------------------------------------------------
|
|
|
|
| 630 |
done. This should inform, not replace, professional care.
|
| 631 |
|
| 632 |
{PAGE_DISCLAIMER}
|
| 633 |
+
|
| 634 |
+
---
|
| 635 |
+
|
| 636 |
+
## このプロジェクトについて(日本語)
|
| 637 |
+
|
| 638 |
+
画像分類AIとLLMチャットアシスタントを組み合わせ、皮膚の状態に関する
|
| 639 |
+
トリアージ(受診要否の判断支援)をどこまで実現できるかを探る、
|
| 640 |
+
研究・ポートフォリオ用のデモです。**医療機器ではありません。**
|
| 641 |
+
|
| 642 |
+
### 主な機能
|
| 643 |
+
- **画像チェック** — 写真をアップロードすると、分類結果と
|
| 644 |
+
Low/Medium/High の重症度、そしてLLMによる平易な解説を表示します。
|
| 645 |
+
- **チャットアシスタント** — 発症時期・経過・症状・きっかけ・既往歴などを
|
| 646 |
+
尋ねながら、写真単体よりも踏み込んだ見解を提示します。日本語・英語の
|
| 647 |
+
両方に対応しています。
|
| 648 |
+
- **モデル性能** — 実際のテストデータに基づく評価指標を公開しています。
|
| 649 |
+
|
| 650 |
+
### 重症度レベル
|
| 651 |
+
- 🔴 **高(High)** — がん関連の疑い、または良性と判別できない曖昧な
|
| 652 |
+
所見、自己免疫・全身性の兆候、危険信号がある場合。早めの受診を
|
| 653 |
+
推奨します。
|
| 654 |
+
- 🟡 **中(Medium)** — 感染症など、正式な診断・処方が必要な状態。
|
| 655 |
+
緊急ではありませんが受診をおすすめします。
|
| 656 |
+
- 🟢 **低(Low)** — 一般的なスキンケアで対応できることが多い状態。
|
| 657 |
+
不安な場合や改善しない場合は受診も選択肢です。
|
| 658 |
+
|
| 659 |
+
### 既知の限界(必ずお読みください)
|
| 660 |
+
- **臨床的な検証は行われていません。** 公開データセットでの精度は、
|
| 661 |
+
実世界での診断的信頼性を意味しません。
|
| 662 |
+
- **データセットのラベルに関する限界。** 一部のタクソノミーでは
|
| 663 |
+
メラノーマと良性のほくろが同一ラベルに含まれる場合があり、
|
| 664 |
+
その場合は安全側に倒して重症度を「高」としています。
|
| 665 |
+
- **肌タイプの偏り。** 公開されている皮膚科データセットは色白の肌タイプに
|
| 666 |
+
偏っている傾向があり、その他の肌タイプでの性能は未検証です。
|
| 667 |
+
- 本ツールはあくまで参考情報であり、専門的な医療の代わりにはなりません。
|
| 668 |
"""
|
| 669 |
|
| 670 |
|
|
|
|
| 680 |
if f.lower().endswith((".jpg", ".jpeg", ".png"))
|
| 681 |
]
|
| 682 |
|
| 683 |
+
with gr.Blocks(title="Skin Disease Detection — Demo / 皮膚疾患検出デモ") as demo:
|
| 684 |
+
gr.Markdown("# 🩺 Skin Disease Detection / 皮膚疾患検出AI")
|
| 685 |
+
gr.Markdown(f"_{PAGE_DISCLAIMER}_\n\n_本ツールは研究・ポートフォリオ用のプロジェクトであり、医療機器ではありません。臨床的な検証は行われていません。実際の受診の代わりではなく、参考情報としてご利用ください。_")
|
| 686 |
|
| 687 |
with gr.Tab("📷 Quick Image Check"):
|
| 688 |
with gr.Row():
|
| 689 |
with gr.Column():
|
| 690 |
+
img_in = gr.Image(type="pil", label="Upload a skin image / 画像をアップロード")
|
| 691 |
+
lang_select = gr.Radio(
|
| 692 |
+
choices=["English", "日本語"], value="English",
|
| 693 |
+
label="Assessment language / 診断結果の言語",
|
| 694 |
+
)
|
| 695 |
if example_images:
|
| 696 |
+
gr.Examples(examples=example_images, inputs=img_in, label="Or try a test image / テスト画像を試す")
|
| 697 |
else:
|
| 698 |
gr.Markdown(
|
| 699 |
"_No bundled test images found. Run `scripts/download_examples.py` "
|
| 700 |
"before deploying, or just upload your own image._"
|
| 701 |
)
|
| 702 |
+
analyze_btn = gr.Button("Analyze image / 画像を解析", variant="primary")
|
| 703 |
with gr.Column():
|
| 704 |
result_md = gr.Markdown()
|
| 705 |
+
analyze_btn.click(diagnose_image, inputs=[img_in, lang_select], outputs=result_md)
|
| 706 |
|
| 707 |
+
with gr.Tab("💬 Chat Assistant / チャット"):
|
| 708 |
gr.ChatInterface(
|
| 709 |
fn=chat_respond,
|
| 710 |
multimodal=True,
|
|
|
|
| 712 |
"Talk through what you're noticing, and optionally attach a photo (📎). "
|
| 713 |
"The assistant will ask several follow-up questions before giving a preliminary "
|
| 714 |
"read with a Low/Medium/High severity call — the more you share, the more "
|
| 715 |
+
"targeted that read can be. You can write in English or Japanese (日本語) — "
|
| 716 |
+
"the assistant will respond in the same language. "
|
| 717 |
+
"気になる症状について自由にお話しください。写真の添付も可能です(📎)。"
|
| 718 |
+
"いくつか質問をした上で、重症度(Low/Medium/High)を含む見解をお伝えします。"
|
| 719 |
+
"日本語でも英語でもご利用いただけます。"
|
| 720 |
),
|
| 721 |
)
|
| 722 |
|
| 723 |
+
with gr.Tab("📊 Model Performance / 性能"):
|
| 724 |
performance_reports = load_performance_reports()
|
| 725 |
if not performance_reports:
|
| 726 |
gr.Markdown(
|
|
|
|
| 728 |
"Run either fine-tuning notebook in this repo, then download the "
|
| 729 |
"`*_metrics.json` and `*_confusion_matrix.png` files it produces and place "
|
| 730 |
"them in this Space's `assets/` folder. This tab picks them up automatically "
|
| 731 |
+
"on the next restart.\n\n"
|
| 732 |
+
"_モデル性能データはまだありません。_ 付属のノートブックを実行し、出力される "
|
| 733 |
+
"`*_metrics.json` と `*_confusion_matrix.png` をこのSpaceの `assets/` フォルダに配置してください。"
|
| 734 |
+
"次回の再起動時に自動的に��映されます。"
|
| 735 |
)
|
| 736 |
else:
|
| 737 |
for report in performance_reports:
|
| 738 |
gr.Markdown(format_performance_markdown(report))
|
| 739 |
if report["image_path"]:
|
| 740 |
+
gr.Image(value=report["image_path"], label="Confusion matrix (test set) / 混同行列", show_label=True)
|
| 741 |
|
| 742 |
+
with gr.Tab("ℹ️ About / 概要"):
|
| 743 |
gr.Markdown(ABOUT_MARKDOWN)
|
| 744 |
|
| 745 |
gr.Markdown(
|
| 746 |
"---\nBuilt with 🤗 Transformers + Gradio. "
|
| 747 |
+
f"Image model: `{IMAGE_MODEL_ID}` · Chat model: `{CHAT_MODEL_ID}` · "
|
| 748 |
+
"Supports English and Japanese (日本語対応)"
|
| 749 |
)
|
| 750 |
|
| 751 |
if __name__ == "__main__":
|
| 752 |
+
demo.launch()
|