Update app.py
Browse files
app.py
CHANGED
|
@@ -27,32 +27,13 @@ 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 |
-
# 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,7 +46,7 @@ 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", "")
|
| 69 |
|
| 70 |
# ---------------------------------------------------------------------------
|
| 71 |
# Urgency tiering — keyword-based rather than an exact-match label dict, so it
|
|
@@ -88,24 +69,9 @@ PROMPT_DOCTOR_KEYWORDS = [
|
|
| 88 |
# benign tumors, common moles, etc.) where general skin-care guidance fits.
|
| 89 |
|
| 90 |
SEVERITY = {
|
| 91 |
-
"urgent": {
|
| 92 |
-
|
| 93 |
-
|
| 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,18 +87,16 @@ def classify_tier(label):
|
|
| 121 |
return "general_care"
|
| 122 |
|
| 123 |
|
| 124 |
-
def severity_line(label
|
| 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.
|
| 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,21 +106,9 @@ KNOWN_DESCRIPTIONS = {
|
|
| 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,107 +167,39 @@ def get_lesion_classifier():
|
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
last_error = f"{resp.status_code} error for model '{model_id}': {detail}"
|
| 251 |
|
| 252 |
-
|
| 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 =
|
| 319 |
desc_txt = f" \n _{desc}_" if desc else ""
|
| 320 |
lines.append(f"- **{label}** — {score:.1f}%{desc_txt}")
|
| 321 |
|
|
@@ -324,12 +208,7 @@ def classify_image(image, language="English"):
|
|
| 324 |
lesion_clf = get_lesion_classifier()
|
| 325 |
if lesion_clf is not None:
|
| 326 |
lesion_preds = lesion_clf(image, top_k=3)
|
| 327 |
-
|
| 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,26 +237,20 @@ def build_classifier_context(preds):
|
|
| 358 |
# Image diagnosis tab (single-shot: upload -> classify -> explain)
|
| 359 |
# ---------------------------------------------------------------------------
|
| 360 |
@spaces.GPU
|
| 361 |
-
def diagnose_image(image
|
| 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
|
| 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 |
-
|
| 371 |
-
lines
|
| 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,7 +259,7 @@ def diagnose_image(image, language="English"):
|
|
| 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 |
-
|
| 390 |
)
|
| 391 |
try:
|
| 392 |
explanation = chat_completion(
|
|
@@ -394,14 +267,11 @@ def diagnose_image(image, language="English"):
|
|
| 394 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 395 |
{"role": "user", "content": prompt},
|
| 396 |
],
|
| 397 |
-
max_tokens=
|
| 398 |
)
|
| 399 |
-
|
| 400 |
-
lines.append(f"\n### {header}\n{explanation}")
|
| 401 |
except Exception as e:
|
| 402 |
-
|
| 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,8 +280,7 @@ def diagnose_image(image, language="English"):
|
|
| 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,19 +326,6 @@ def _stage_note(user_turn_count):
|
|
| 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,7 +334,6 @@ def chat_respond(message, history):
|
|
| 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,13 +367,7 @@ def chat_respond(message, history):
|
|
| 512 |
try:
|
| 513 |
return chat_completion(messages, max_tokens=500)
|
| 514 |
except Exception as e:
|
| 515 |
-
|
| 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,41 +479,6 @@ about what it isn't: a validated medical device.
|
|
| 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,31 +494,27 @@ if os.path.isdir(EXAMPLES_DIR):
|
|
| 680 |
if f.lower().endswith((".jpg", ".jpeg", ".png"))
|
| 681 |
]
|
| 682 |
|
| 683 |
-
with gr.Blocks(title="Skin Disease Detection — Demo
|
| 684 |
-
gr.Markdown("# 🩺 Skin Disease Detection
|
| 685 |
-
gr.Markdown(f"_{PAGE_DISCLAIMER}_
|
| 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
|
| 703 |
with gr.Column():
|
| 704 |
result_md = gr.Markdown()
|
| 705 |
-
analyze_btn.click(diagnose_image, inputs=
|
| 706 |
|
| 707 |
-
with gr.Tab("💬 Chat Assistant
|
| 708 |
gr.ChatInterface(
|
| 709 |
fn=chat_respond,
|
| 710 |
multimodal=True,
|
|
@@ -712,15 +522,11 @@ with gr.Blocks(title="Skin Disease Detection — Demo / 皮膚疾患検出デモ
|
|
| 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.
|
| 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,25 +534,21 @@ with gr.Blocks(title="Skin Disease Detection — Demo / 皮膚疾患検出デモ
|
|
| 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.
|
| 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)
|
| 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()
|
|
|
|
| 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", "openai/gpt-oss-120b")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
# 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 |
# benign tumors, common moles, etc.) where general skin-care guidance fits.
|
| 70 |
|
| 71 |
SEVERITY = {
|
| 72 |
+
"urgent": {"label": "High", "emoji": "🔴", "note": "recommend seeing a dermatologist/doctor soon"},
|
| 73 |
+
"prompt_doctor_visit": {"label": "Medium", "emoji": "🟡", "note": "needs a proper diagnosis/prescription, not an emergency"},
|
| 74 |
+
"general_care": {"label": "Low", "emoji": "🟢", "note": "typically manageable with general skin care"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
|
|
|
|
| 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 |
"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 |
|
| 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 |
+
resp = requests.post(
|
| 174 |
+
ROUTER_URL,
|
| 175 |
+
headers={"Authorization": f"Bearer {HF_TOKEN}"},
|
| 176 |
+
json={"model": CHAT_MODEL_ID, "messages": messages, "max_tokens": max_tokens},
|
| 177 |
+
timeout=60,
|
| 178 |
+
)
|
| 179 |
+
if not resp.ok:
|
| 180 |
+
# Surface the actual reason from the response body (e.g. "model not
|
| 181 |
+
# supported by any provider") instead of just the generic status line —
|
| 182 |
+
# that detail is what actually explains a 400/404, not "Bad Request".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
try:
|
| 184 |
detail = resp.json()
|
|
|
|
| 185 |
except Exception:
|
| 186 |
detail = resp.text
|
| 187 |
+
raise RuntimeError(f"{resp.status_code} error from router for model '{CHAT_MODEL_ID}': {detail}")
|
| 188 |
+
data = resp.json()
|
| 189 |
+
return data["choices"][0]["message"]["content"]
|
| 190 |
|
|
|
|
| 191 |
|
| 192 |
+
def classify_image(image):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = KNOWN_DESCRIPTIONS.get(label.lower(), "")
|
| 203 |
desc_txt = f" \n _{desc}_" if desc else ""
|
| 204 |
lines.append(f"- **{label}** — {score:.1f}%{desc_txt}")
|
| 205 |
|
|
|
|
| 208 |
lesion_clf = get_lesion_classifier()
|
| 209 |
if lesion_clf is not None:
|
| 210 |
lesion_preds = lesion_clf(image, top_k=3)
|
| 211 |
+
lines.append("\n_Second opinion from a lesion-specific model (melanoma is a distinct label here):_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
# 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 |
+
lines = ["### 🔬 Classifier prediction\n"] + lines_list
|
| 250 |
+
lines.append(f"\n{severity_line(preds[0]['label'])}")
|
|
|
|
| 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 |
"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 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 268 |
{"role": "user", "content": prompt},
|
| 269 |
],
|
| 270 |
+
max_tokens=300,
|
| 271 |
)
|
| 272 |
+
lines.append(f"\n### 🤖 Assessment\n{explanation}")
|
|
|
|
| 273 |
except Exception as e:
|
| 274 |
+
lines.append(f"\n_(Assessment unavailable: {e})_")
|
|
|
|
|
|
|
| 275 |
else:
|
| 276 |
lines.append(
|
| 277 |
"\n_Add an `HF_TOKEN` secret to this Space to also get a plain-language "
|
|
|
|
| 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 |
)
|
| 327 |
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
def chat_respond(message, history):
|
| 330 |
if not HF_TOKEN:
|
| 331 |
return (
|
|
|
|
| 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 |
try:
|
| 368 |
return chat_completion(messages, max_tokens=500)
|
| 369 |
except Exception as e:
|
| 370 |
+
return f"Sorry, I hit an error talking to the model: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
|
| 373 |
# ---------------------------------------------------------------------------
|
|
|
|
| 479 |
done. This should inform, not replace, professional care.
|
| 480 |
|
| 481 |
{PAGE_DISCLAIMER}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
"""
|
| 483 |
|
| 484 |
|
|
|
|
| 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 |
"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 |
"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()
|