Pro-Coder commited on
Commit
753c901
·
verified ·
1 Parent(s): ac3f2a3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +51 -249
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").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,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", "").strip()
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
- "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,18 +87,16 @@ def classify_tier(label):
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,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
- 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,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
- 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,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, 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,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
- f"case seems ambiguous. Keep it to 5-7 sentences. {lang_instruction}"
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=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,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
- 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,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 / 皮膚疾患検出デモ") 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,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. 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,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.\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()
 
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()