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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +249 -51
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", "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,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": {"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,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
- 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,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
- 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,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
- 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,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=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,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
- return f"Sorry, I hit an error talking to the model: {e}"
 
 
 
 
 
 
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()