"""
OED Diagnosis Web App — Hugging Face Space (Gradio)
====================================================
Minimal UI: password gate → image + prompt → diagnosis.
"""
# ---- Monkey-patch: fix gradio_client bug with boolean JSON schemas ----
import gradio_client.utils as _gc_utils
_orig_get_type = _gc_utils.get_type
def _safe_get_type(schema):
if not isinstance(schema, dict):
return "Any"
try:
return _orig_get_type(schema)
except (TypeError, AttributeError, KeyError):
return "Any"
_gc_utils.get_type = _safe_get_type
_orig_inner = _gc_utils._json_schema_to_python_type
def _safe_inner(schema, defs=None):
if not isinstance(schema, dict):
return "Any"
try:
return _orig_inner(schema, defs)
except (TypeError, AttributeError, KeyError):
return "Any"
_gc_utils._json_schema_to_python_type = _safe_inner
_orig_outer = _gc_utils.json_schema_to_python_type
def _safe_outer(schema, defs=None):
try:
return _orig_outer(schema, defs)
except (TypeError, AttributeError, KeyError):
return "Any"
_gc_utils.json_schema_to_python_type = _safe_outer
# ---- End monkey-patch ----
import os
import gradio as gr
import matplotlib.pyplot as plt
import pandas as pd
import torch
import torch.nn.functional as F
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
import google.generativeai as genai
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
APP_PASSWORD = os.getenv("APP_PASSWORD", "9890")
PLIP_MODEL_ID = "vinid/plip"
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# Google Gemini API — free tier: 1500 requests/day for 2.0-flash, no card needed.
# Get a free key at: https://aistudio.google.com/app/apikey
# Add it as a Space Secret named GEMINI_API_KEY.
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if GEMINI_API_KEY:
genai.configure(api_key=GEMINI_API_KEY)
# Models tried in order. Free-tier daily quotas (as of 2025/2026):
# gemini-2.5-flash-lite → 1000 RPD, 15 RPM (highest daily quota)
# gemini-2.5-flash → 250 RPD, 10 RPM (best quality)
# gemini-2.0-flash → 200 RPD, 15 RPM (stable fallback)
# gemini-2.0-flash-lite → 200 RPD, 30 RPM (fastest, lowest cost)
# Order: prioritize highest daily quota first to maximize free usage.
GEMINI_MODELS = [
"gemini-2.5-flash-lite",
"gemini-2.5-flash",
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
]
# Relaxed safety thresholds — medical/pathology images can be flagged otherwise.
GEMINI_SAFETY = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]
DEFAULT_PROMPT = (
"Normal oral mucosa with regular stratified squamous epithelium\n"
"Low-grade oral epithelial dysplasia with mild architectural and "
"cytological atypia confined to the lower third of the epithelium\n"
"High-grade oral epithelial dysplasia with marked atypia extending "
"to two-thirds or full thickness of the epithelium\n"
"Oral squamous cell carcinoma with invasion through the basement "
"membrane into the underlying connective tissue"
)
# Multi-prompt ensembling: for each class we use SEVERAL semantically
# equivalent prompts and average their similarity scores. This is a
# well-established CLIP technique (Radford et al., 2021) that:
# * reduces sensitivity to prompt wording bias
# * smooths model uncertainty across phrasings
# * works equally well for ALL classes (no bias toward any single one)
# All prompt sets are balanced — exactly 5 prompts per class, matched in
# linguistic style and pathology-specific vocabulary.
ENSEMBLE_PROMPTS = {
"Normal oral mucosa with regular stratified squamous epithelium": [
"A histopathology image of normal oral mucosa with regular "
"stratified squamous epithelium and intact basement membrane.",
"H&E-stained section of healthy oral epithelium showing orderly "
"cell maturation from basal to superficial layers.",
"Normal oral mucosa, no cytological atypia, normal cell polarity, "
"preserved tissue architecture.",
"Benign oral squamous epithelium with regular keratinization and "
"no dysplastic features.",
"Histology of healthy oral mucosa with uniform basal cells and "
"normal nuclear-to-cytoplasmic ratio.",
],
"Low-grade oral epithelial dysplasia with mild architectural and "
"cytological atypia confined to the lower third of the epithelium": [
"A histopathology image of low-grade oral epithelial dysplasia "
"with atypia limited to the lower third of the epithelium.",
"H&E section showing mild oral dysplasia: basal cell hyperplasia, "
"mild nuclear pleomorphism, preserved upper-layer maturation.",
"Low-grade oral epithelial dysplasia with subtle architectural "
"disorder confined to the basal and parabasal layers.",
"Mild dysplastic changes in oral squamous epithelium with "
"preserved overall stratification and surface differentiation.",
"Oral epithelium showing minor cytological atypia in the lower "
"third, with regular maturation above; low-grade dysplasia.",
],
"High-grade oral epithelial dysplasia with marked atypia extending "
"to two-thirds or full thickness of the epithelium": [
"A histopathology image of high-grade oral epithelial dysplasia "
"with marked atypia extending into the upper two-thirds.",
"H&E section showing severe dysplastic changes through the full "
"thickness of the oral epithelium with abnormal mitoses.",
"High-grade oral dysplasia: pronounced nuclear pleomorphism, "
"hyperchromasia, loss of polarity, no basement membrane invasion.",
"Oral squamous epithelium with full-thickness atypia, marked "
"loss of stratification, but intact basement membrane.",
"Carcinoma in situ-like features in oral epithelium: severe "
"atypia, abnormal mitoses, preserved basement membrane integrity.",
],
"Oral squamous cell carcinoma with invasion through the basement "
"membrane into the underlying connective tissue": [
"A histopathology image of invasive oral squamous cell carcinoma "
"with malignant cells breaching the basement membrane.",
"H&E section of oral SCC: malignant squamous cells infiltrating "
"the underlying stroma in irregular nests and cords.",
"Invasive oral squamous cell carcinoma with stromal invasion, "
"keratin pearls, and desmoplastic reaction.",
"Oral SCC showing malignant epithelial proliferation invading "
"the lamina propria with intercellular bridges and keratinization.",
"Histology of oral squamous cell carcinoma: irregular tumor "
"nests, nuclear atypia, mitotic figures, stromal invasion.",
],
}
# Mapping: English class (sent to PLIP) → (Arabic group label, Arabic description, color)
# These match the 4 groups defined in the research proposal.
LABEL_AR_MAP = {
# Group 1 — Normal
"Normal oral mucosa with regular stratified squamous epithelium": (
"المجموعة الأولى", "مخاطية فموية سليمة", "#27ae60",
),
# Group 2 — Low-grade OED
"Low-grade oral epithelial dysplasia with mild architectural and "
"cytological atypia confined to the lower third of the epithelium": (
"المجموعة الثانية", "خلل تنسج فموي منخفض الدرجة", "#f1c40f",
),
# Group 3 — High-grade OED
"High-grade oral epithelial dysplasia with marked atypia extending "
"to two-thirds or full thickness of the epithelium": (
"المجموعة الثالثة", "خلل تنسج فموي مرتفع الدرجة", "#e67e22",
),
# Group 4 — OSCC
"Oral squamous cell carcinoma with invasion through the basement "
"membrane into the underlying connective tissue": (
"المجموعة الرابعة", "سرطان الخلايا الحرشفية الفموي", "#c0392b",
),
# Backward-compatible short keys (in case user shortens the prompt)
"Normal oral mucosa": (
"المجموعة الأولى", "مخاطية فموية سليمة", "#27ae60",
),
"Low-grade oral epithelial dysplasia": (
"المجموعة الثانية", "خلل تنسج فموي منخفض الدرجة", "#f1c40f",
),
"High-grade oral epithelial dysplasia": (
"المجموعة الثالثة", "خلل تنسج فموي مرتفع الدرجة", "#e67e22",
),
"Oral squamous cell carcinoma": (
"المجموعة الرابعة", "سرطان الخلايا الحرشفية الفموي", "#c0392b",
),
}
def label_info(label):
"""Return (group_ar, description_ar, color, english) for a class label."""
if label in LABEL_AR_MAP:
group, desc, color = LABEL_AR_MAP[label]
return group, desc, color, label
return "", label, "#6c3483", label
# ---------------------------------------------------------------------------
# Model setup — only PLIP is loaded locally. Q&A goes through HF Inference API.
# ---------------------------------------------------------------------------
print(f"Loading PLIP ({PLIP_MODEL_ID}) on {DEVICE} ...")
model = CLIPModel.from_pretrained(PLIP_MODEL_ID).to(DEVICE).eval()
processor = CLIPProcessor.from_pretrained(PLIP_MODEL_ID)
print(" ✓ PLIP ready.")
print("Q&A will use HuggingFace Inference API (no local VLM).")
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
def parse_prompt(prompt_text):
if not prompt_text:
return []
lines = [line.strip() for line in prompt_text.replace(",", "\n").split("\n")]
return [line for line in lines if line]
def color_for(label, index=0, total=0):
if label in LABEL_AR_MAP:
return LABEL_AR_MAP[label][2]
palette = ["#27ae60", "#f1c40f", "#e67e22", "#c0392b",
"#2980b9", "#8e44ad", "#16a085", "#34495e"]
return palette[index % len(palette)]
def make_plot(df):
df_sorted = df.sort_values("Score (%)", ascending=True).reset_index(drop=True)
fig, ax = plt.subplots(figsize=(8, max(2.5, 0.55 * len(df_sorted) + 1.5)))
fig.patch.set_facecolor("#ffffff")
ax.set_facecolor("#fafbfc")
colors = [color_for(l, i, len(df_sorted))
for i, l in enumerate(df_sorted["Class"])]
bars = ax.barh(df_sorted["Class"], df_sorted["Score (%)"],
color=colors, edgecolor="white", linewidth=1.5)
ax.set_xlim(0, 105)
ax.set_xlabel("Score (%)", fontsize=11, color="#34495e")
ax.tick_params(axis="y", labelsize=10, colors="#2c3e50")
ax.tick_params(axis="x", labelsize=9, colors="#7f8c8d")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#bdc3c7")
ax.spines["bottom"].set_color("#bdc3c7")
ax.grid(axis="x", linestyle="--", alpha=0.3)
for bar, v in zip(bars, df_sorted["Score (%)"]):
ax.text(v + 1.5, bar.get_y() + bar.get_height() / 2,
f"{v:.1f}%", va="center", fontsize=10,
fontweight="bold", color="#2c3e50")
plt.tight_layout()
return fig
def _score_image(pil_img, prompts):
"""Encode image once, score it against a list of text prompts."""
with torch.no_grad():
inputs = processor(text=prompts, images=pil_img, return_tensors="pt",
padding=True, truncation=True).to(DEVICE)
outputs = model(**inputs)
return outputs.logits_per_image[0] # raw similarity logits
def _ensemble_score(pil_img, class_label):
"""Average similarity across the ensemble of prompts for one class.
Returns a single scalar logit for that class.
"""
prompts = ENSEMBLE_PROMPTS.get(class_label)
if not prompts:
return _score_image(pil_img, [class_label])[0].item()
logits = _score_image(pil_img, prompts)
return logits.mean().item()
def diagnose(image, prompt_text):
if image is None:
msg = """
⚠️
لم يتم رفع صورة
ارفع صورة نسيجية أولاً ثم اضغط زر التشخيص.
"""
return msg, None
classes = parse_prompt(prompt_text)
if not classes:
msg = """
📝
البرومبت فارغ
اكتب وصفاً (أو عدة أوصاف، كل واحد في سطر) ثم اضغط التشخيص.
"""
return msg, None
pil_img = image if isinstance(image, Image.Image) else Image.fromarray(image)
pil_img = pil_img.convert("RGB")
# Use multi-prompt ensembling when ALL classes have an ensemble template.
# Otherwise fall back to single-prompt scoring (custom prompts the user typed).
use_ensemble = all(c in ENSEMBLE_PROMPTS for c in classes)
with torch.no_grad():
if use_ensemble:
scalar_logits = torch.tensor(
[_ensemble_score(pil_img, c) for c in classes],
device=DEVICE,
)
else:
scalar_logits = _score_image(pil_img, classes)
# Temperature scaling: PLIP's default logits are very sharp because
# the contrastive temperature was learned on web-scale data. Dividing
# by a small constant (T=1.5) gives better-calibrated probabilities
# for downstream interpretation — this does NOT change ranking, only
# the spread of confidence values across classes.
TEMPERATURE = 1.5
if len(classes) == 1:
scores = torch.sigmoid(scalar_logits / 10.0).cpu().numpy()
else:
scores = F.softmax(scalar_logits / TEMPERATURE, dim=0).cpu().numpy()
df = pd.DataFrame({
"Class": classes,
"Score (%)": (scores * 100).round(2),
}).sort_values("Score (%)", ascending=False).reset_index(drop=True)
top = df.iloc[0]
top_group, top_desc, top_color, top_en = label_info(top["Class"])
rows_html = ""
for _, row in df.iterrows():
g, d, c, en = label_info(row["Class"])
is_top = row["Class"] == top["Class"]
rows_html += f"""
{g or '—'}
{d}
{row["Score (%)"]:.1f}%
"""
group_chip = (f""
f"{top_group}
" if top_group else "")
summary = f"""
🩺 التشخيص الأرجح
{group_chip}
{top_desc}
{top_en}
📊 ترتيب كل المجموعات
{rows_html}
⚕️ نتيجة استرشادية من نموذج PLIP — لا تُغني عن تقييم المشرّح المرضي.
"""
fig = make_plot(df)
return summary, fig
# ---------------------------------------------------------------------------
# Q&A inference — Google Gemini API
# Free tier: 1,500 requests/day for gemini-2.0-flash, no credit card required.
# ---------------------------------------------------------------------------
def _warning_html(title, body):
return (
""
"
⚠️
"
f"
{title}
"
f"
{body}
"
"
"
)
def _qa_success_html(question, answer, model_used):
return (
""
"
💬 إجابة النموذج
"
"
السؤال: "
f"{question}
"
f"
{answer}
"
f"
🤖 Google Gemini ({model_used}). "
"للاستكشاف والتعليم فقط — ليس بديلاً عن المشرّح المرضي.
"
"
"
)
def answer_question(image, question):
if image is None:
return _warning_html(
"لم يتم رفع صورة",
"ارفع صورة نسيجية أولاً (في خانة الصورة بالأعلى) ثم اكتب سؤالك.",
)
q = (question or "").strip()
if not q:
return _warning_html(
"السؤال فارغ",
"اكتب سؤالك أو طلبك بالإنجليزية (يعطي نتائج أفضل) أو بالعربية.",
)
if not GEMINI_API_KEY:
return (
""
"
🔑
"
"
"
"GEMINI_API_KEY غير مُعرَّف
"
"
"
"يحتاج هذا الوضع مفتاح Google Gemini API (مجاني تماماً). "
"احصل عليه من:"
"
"
"🔗
"
"https://aistudio.google.com/app/apikey"
"
"
"ثم أضفه في Space →
Settings → "
"
Variables and secrets → "
"
New secret:"
"
"
"- Name:
GEMINI_API_KEY "
"- Value: الصق المفتاح
"
"
"
"ثم اضغط
Restart Space."
"
"
"
"
)
pil_img = image if isinstance(image, Image.Image) else Image.fromarray(image)
pil_img = pil_img.convert("RGB")
# Resize large images to keep API requests fast
if max(pil_img.size) > 1280:
pil_img.thumbnail((1280, 1280))
# Try models in order, fall through on failure
errors = []
for model_name in GEMINI_MODELS:
try:
gemini = genai.GenerativeModel(
model_name,
safety_settings=GEMINI_SAFETY,
)
response = gemini.generate_content(
[q, pil_img],
generation_config={
"temperature": 0.4,
"max_output_tokens": 800,
},
)
answer = (response.text or "").strip()
if answer:
return _qa_success_html(q, answer, model_name)
errors.append(f"{model_name}: empty response "
"(possibly blocked by safety filter)")
except Exception as e:
errors.append(f"{model_name}: "
f"{type(e).__name__}: {str(e)[:200]}")
continue
# Detect specific failure type from collected errors
all_errors_text = " ".join(errors).lower()
is_rate_limited = ("429" in all_errors_text
or "resourceexhausted" in all_errors_text
or "quota" in all_errors_text)
is_not_found = "404" in all_errors_text or "not found" in all_errors_text
if is_rate_limited and not is_not_found:
# All models hit rate limit (probably per-minute limit, not daily)
title = "⏱️ تجاوز سرعة الطلبات (Rate Limit)"
body = (
"النموذج المجاني محدود بـ 10-30 طلب في الدقيقة. "
"انتظر ~60 ثانية ثم أعد المحاولة."
"
"
"إذا كنت أرسلت أكثر من 1000 طلب اليوم، فقد تكون تجاوزت الحصة "
"اليومية المجانية، وستحتاج الانتظار حتى الغد أو ترقية الخطة "
"في Google AI Studio."
)
color = "#e67e22"
elif is_not_found:
title = "❓ النماذج غير متاحة"
body = (
"النماذج المُحدَّدة غير متاحة على API. هذا يحدث عند "
"تقديم نسخة جديدة من Gemini. تواصل مع مطوّر التطبيق "
"لتحديث قائمة النماذج."
)
color = "#c0392b"
else:
title = "❌ تعذّر الحصول على إجابة"
body = (
"الأسباب المحتملة:"
""
"- المفتاح
GEMINI_API_KEY غير صالح أو منتهي "
"- الصورة كبيرة جداً أو بصيغة غير مدعومة
"
"- مشكلة شبكة مؤقتة
"
"
"
)
color = "#c0392b"
err_list = "
• ".join(errors) if errors else "Unknown error"
return (
""
f"
"
f"{title}
"
f"
{body}
"
"
"
""
"تفاصيل تقنية
"
f""
f"• {err_list}"
" "
"
"
)
# ---------------------------------------------------------------------------
# Auth gate
# ---------------------------------------------------------------------------
def verify_password(password):
if password == APP_PASSWORD:
return (
gr.update(visible=False), # hide gate
gr.update(visible=True), # show main
"", # clear error
"", # clear password field
"yes", # signal to JS: save to localStorage
)
return (
gr.update(visible=True),
gr.update(visible=False),
"❌ كلمة المرور غير صحيحة
",
"",
"no",
)
def initial_auth_check(remembered_flag):
if remembered_flag == "yes":
return gr.update(visible=False), gr.update(visible=True)
return gr.update(visible=True), gr.update(visible=False)
# ---------------------------------------------------------------------------
# CSS — modern, responsive, RTL Arabic
# ---------------------------------------------------------------------------
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700;800&display=swap');
:root {
--primary: #6c3483;
--primary-light: #884ea0;
--bg: #f5f3f7;
--surface: #ffffff;
--text: #2c3e50;
--text-muted: #7f8c8d;
--border: #e0d7e6;
--success: #27ae60;
--warning: #f39c12;
--danger: #c0392b;
}
body, .gradio-container {
direction: rtl !important;
font-family: 'Cairo', 'Segoe UI', Tahoma, Arial, sans-serif !important;
background: linear-gradient(135deg, #f5f3f7 0%, #ebe1f0 100%) !important;
min-height: 100vh;
}
.gradio-container {
max-width: 1100px !important;
margin: 0 auto !important;
padding: 12px !important;
}
/* ---------- Header ---------- */
.app-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 28px 22px;
border-radius: 18px;
margin-bottom: 18px;
box-shadow: 0 10px 30px rgba(108, 52, 131, 0.25);
text-align: center;
}
.app-header h1 {
margin: 0 0 8px 0 !important;
font-size: 1.8em !important;
font-weight: 800 !important;
color: white !important;
}
.app-header .subtitle { font-size: 0.95em; opacity: 0.95; margin: 0; }
.app-header .badge {
display: inline-block;
background: rgba(255,255,255,0.2);
padding: 4px 14px;
border-radius: 20px;
font-size: 0.85em;
margin-top: 10px;
font-weight: 600;
}
/* ---------- Login screen ---------- */
.login-card {
background: white;
border-radius: 20px;
padding: 30px 26px 18px;
text-align: center;
box-shadow: 0 12px 40px rgba(108, 52, 131, 0.18);
max-width: 440px;
margin: 60px auto 20px;
border-top: 5px solid var(--primary);
}
.login-icon { font-size: 3.5em; margin-bottom: 6px; }
.login-title {
color: var(--primary) !important;
margin: 8px 0 6px !important;
font-size: 1.5em !important;
font-weight: 800 !important;
}
.login-sub {
color: var(--text-muted);
font-size: 0.95em;
margin: 0 0 8px;
line-height: 1.6;
}
.login-error {
background: #fdedec;
color: #c0392b;
padding: 10px 14px;
border-radius: 8px;
margin-top: 6px;
font-weight: 700;
text-align: center;
border-right: 4px solid #c0392b;
}
/* ---------- Cards ---------- */
.gradio-container .block,
.gradio-container .form,
.gradio-container fieldset {
background: var(--surface) !important;
border-radius: 14px !important;
border: 1px solid var(--border) !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.04) !important;
}
label, .label, span[data-testid="block-label"] {
font-family: 'Cairo', sans-serif !important;
font-weight: 700 !important;
font-size: 0.95em !important;
color: var(--text) !important;
text-align: right !important;
direction: rtl !important;
}
input[type=text], input[type=password], textarea,
.gr-textbox textarea, .gr-textbox input {
font-family: 'Cairo', sans-serif !important;
border-radius: 10px !important;
border: 2px solid var(--border) !important;
padding: 10px 14px !important;
font-size: 1em !important;
transition: all 0.2s;
}
input:focus, textarea:focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 3px rgba(108, 52, 131, 0.1) !important;
outline: none !important;
}
button.primary, .gr-button-primary, button[variant="primary"] {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%) !important;
color: white !important;
border: none !important;
border-radius: 12px !important;
padding: 14px 24px !important;
font-size: 1.1em !important;
font-weight: 700 !important;
font-family: 'Cairo', sans-serif !important;
box-shadow: 0 4px 14px rgba(108, 52, 131, 0.3) !important;
cursor: pointer;
transition: all 0.2s;
width: 100% !important;
margin-top: 10px;
min-height: 52px;
}
button.primary:hover, .gr-button-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(108, 52, 131, 0.4) !important;
}
.gradio-container .image-container,
.gradio-container [data-testid="image"] {
border-radius: 14px !important;
border: 2px dashed var(--primary-light) !important;
background: #faf7fc !important;
}
/* ---------- Result card ---------- */
.result-card {
background: white;
border-radius: 16px;
padding: 22px 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
text-align: center;
margin: 8px 0;
}
.result-card.warning-card {
background: linear-gradient(135deg, #fff8e6 0%, #ffeaa7 100%);
border: 2px solid var(--warning);
}
.result-icon { font-size: 3em; margin-bottom: 10px; }
.result-label {
font-size: 0.9em;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.result-title {
font-size: 1.5em;
font-weight: 800;
margin: 8px 0 4px 0;
line-height: 1.3;
}
.result-sub { color: var(--text); font-size: 1em; margin-top: 6px; }
.confidence-wrap {
display: flex; align-items: center; gap: 12px;
margin: 14px 0 8px 0; direction: ltr;
}
.confidence-bar-bg {
flex: 1; height: 14px; background: #ecf0f1;
border-radius: 10px; overflow: hidden;
}
.confidence-bar {
height: 100%; border-radius: 10px;
transition: width 0.6s ease;
}
.confidence-val {
font-weight: 800; font-size: 1.1em;
min-width: 60px; text-align: left; color: var(--text);
}
.result-note {
margin-top: 14px;
padding: 10px 14px;
background: #fef9e7;
border-right: 4px solid var(--warning);
border-radius: 8px;
font-size: 0.88em;
color: #7d6608;
text-align: right;
}
.top-group-chip {
display: inline-block;
color: white;
font-weight: 800;
font-size: 0.95em;
padding: 6px 16px;
border-radius: 20px;
margin: 8px 0 4px;
letter-spacing: 0.5px;
}
.result-en {
font-size: 0.85em;
color: var(--text-muted);
font-style: italic;
direction: ltr;
margin: 4px 0 10px;
}
.all-classes {
margin-top: 22px;
padding-top: 18px;
border-top: 1px dashed var(--border);
text-align: right;
}
.all-classes-title {
font-weight: 700;
color: var(--primary);
margin-bottom: 12px;
font-size: 1em;
}
.cls-row {
background: #fafbfc;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--border);
}
.cls-row-top {
background: #f8f5fb;
border: 1.5px solid var(--primary-light);
box-shadow: 0 2px 6px rgba(108, 52, 131, 0.1);
}
.cls-row-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.cls-group {
color: white;
font-weight: 700;
font-size: 0.78em;
padding: 3px 10px;
border-radius: 12px;
white-space: nowrap;
}
.cls-desc {
flex: 1;
font-size: 0.9em;
font-weight: 600;
color: var(--text);
min-width: 140px;
}
.cls-val {
font-weight: 800;
font-size: 1.05em;
white-space: nowrap;
}
.cls-bar-bg {
height: 8px;
background: #ecf0f1;
border-radius: 6px;
overflow: hidden;
}
.cls-bar {
height: 100%;
border-radius: 6px;
transition: width 0.6s ease;
}
.prompt-hint {
font-size: 0.85em;
color: var(--text-muted);
margin: 6px 0 0;
padding: 8px 12px;
background: #f8f5fb;
border-right: 3px solid var(--primary-light);
border-radius: 6px;
}
.qa-question {
background: #f8f5fb;
padding: 12px 16px;
border-radius: 8px;
margin: 12px 0;
text-align: right;
font-size: 0.95em;
color: var(--text);
}
.qa-answer {
background: white;
border: 1.5px solid var(--primary-light);
padding: 16px 18px;
border-radius: 10px;
margin: 8px 0;
text-align: right;
font-size: 1em;
line-height: 1.8;
color: var(--text);
white-space: pre-wrap;
}
/* Tabs styling */
.tab-nav button {
font-family: 'Cairo', sans-serif !important;
font-size: 1em !important;
font-weight: 700 !important;
padding: 12px 22px !important;
}
.tab-nav button.selected {
background: linear-gradient(135deg, var(--primary), var(--primary-light)) !important;
color: white !important;
}
footer, .gradio-container > footer { display: none !important; }
.show-api { display: none !important; }
/* ---------- Mobile ---------- */
@media (max-width: 768px) {
.gradio-container { padding: 8px !important; }
.app-header { padding: 20px 14px; border-radius: 14px; }
.app-header h1 { font-size: 1.35em !important; }
.app-header .subtitle { font-size: 0.85em; }
.login-card { margin: 24px auto; padding: 24px 18px; }
.login-title { font-size: 1.25em !important; }
.result-title { font-size: 1.2em; }
.result-card { padding: 18px 14px; }
button.primary, .gr-button-primary {
padding: 16px 22px !important;
font-size: 1em !important;
}
.gradio-container .gr-row > .gr-column { flex: 1 1 100% !important; }
input[type=text], input[type=password], textarea {
font-size: 16px !important;
}
}
@media (max-width: 480px) {
.app-header h1 { font-size: 1.15em !important; }
.result-title { font-size: 1.05em; }
}
"""
HEADER_HTML = """
"""
LOGIN_HTML = """
🔐
الدخول إلى التطبيق
أدخل كلمة المرور للوصول إلى تطبيق تشخيص سوء التصنع الفموي
"""
INITIAL_RESULT_HTML = """
🧪
ارفع صورة واضغط «شخّص الصورة» لعرض النتيجة هنا.
"""
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
with gr.Blocks(title="تشخيص OED — PLIP",
theme=gr.themes.Soft(primary_hue="purple", neutral_hue="slate"),
css=CUSTOM_CSS) as demo:
# Hidden signal for JS/localStorage communication
js_signal = gr.Textbox(visible=False, value="", elem_id="js_signal")
# =================== LOGIN GATE ===================
with gr.Column(visible=True) as login_gate:
gr.HTML(LOGIN_HTML)
with gr.Row():
with gr.Column(scale=1, min_width=280):
password_input = gr.Textbox(
label="كلمة المرور",
type="password",
placeholder="••••",
show_label=True,
)
login_error = gr.HTML(value="")
login_btn = gr.Button("🔓 دخول", variant="primary", size="lg")
# =================== MAIN APP ===================
with gr.Column(visible=False) as main_app:
gr.HTML(HEADER_HTML)
# ===== SHARED IMAGE INPUT — used by both tabs =====
with gr.Row():
with gr.Column(scale=1):
image_input = gr.Image(
type="pil",
label="📷 الصورة النسيجية (تُستخدم في كلا الوضعَين)",
height=340,
sources=["upload", "clipboard"],
)
with gr.Tabs() as tabs:
# ---------- Tab 1: Q&A (DEFAULT — Gemini API) ----------
with gr.Tab("💬 سؤال وجواب حر"):
with gr.Row(equal_height=False):
with gr.Column(scale=1, min_width=300):
qa_prompt_input = gr.Textbox(
label="💬 اكتب سؤالك / طلبك",
lines=4,
placeholder=(
"أمثلة:\n"
"• Describe what you see in this image.\n"
"• Is there any sign of dysplasia?\n"
"• What histological features are present?\n"
"• Are there any abnormal cells?"
),
)
gr.HTML(
""
"💡 يستخدم هذا الوضع نموذج "
"Google Gemini 2.5 Flash (مجاني، "
"حتى 1000 طلب/يوم). الإجابة تأخذ ~3-5 ثواني. "
"الأسئلة بالإنجليزية تعطي نتائج أدق."
"
"
)
qa_btn = gr.Button(
"💡 احصل على الإجابة",
variant="primary",
size="lg",
)
with gr.Column(scale=1, min_width=300):
qa_output = gr.HTML(value=(
""
"
💭
"
"
اكتب سؤالاً عن الصورة "
"المرفوعة في الأعلى، ثم اضغط "
"«احصل على الإجابة».
"
"
"
))
# ---------- Tab 2: Classification (PLIP) ----------
with gr.Tab("🔬 تصنيف ضمن الفئات الأربع"):
with gr.Row(equal_height=False):
with gr.Column(scale=1, min_width=300):
prompt_input = gr.Textbox(
label="📝 برومبت الفئات (وصف كل فئة في سطر)",
value=DEFAULT_PROMPT,
lines=6,
placeholder=(
"اكتب كل فئة في سطر منفصل، أو اترك "
"الافتراضي للفئات الأربع للدراسة."
),
)
gr.HTML(
""
"💡 يستخدم هذا الوضع نموذج PLIP المتخصص بالباثولوجيا "
"ويعطي نسبة تشابه لكل فئة. البرومبت بالإنجليزية يعطي "
"أفضل نتائج."
"
"
)
diagnose_btn = gr.Button(
"🔍 شخّص الصورة",
variant="primary",
size="lg",
)
with gr.Column(scale=1, min_width=300):
summary_output = gr.HTML(value=INITIAL_RESULT_HTML)
plot_output = gr.Plot(
label="📊 نسبة التشابه لكل فئة",
show_label=True,
)
# =================== EVENTS ===================
SAVE_AUTH_JS = """
(signal) => {
if (signal === 'yes') {
try { localStorage.setItem('oed_authed', 'yes'); } catch (e) {}
}
return [];
}
"""
login_btn.click(
verify_password,
inputs=[password_input],
outputs=[login_gate, main_app, login_error, password_input, js_signal],
).then(fn=None, inputs=[js_signal], outputs=[], js=SAVE_AUTH_JS)
password_input.submit(
verify_password,
inputs=[password_input],
outputs=[login_gate, main_app, login_error, password_input, js_signal],
).then(fn=None, inputs=[js_signal], outputs=[], js=SAVE_AUTH_JS)
diagnose_btn.click(
diagnose,
inputs=[image_input, prompt_input],
outputs=[summary_output, plot_output],
)
qa_btn.click(
answer_question,
inputs=[image_input, qa_prompt_input], # use the SHARED image input
outputs=[qa_output],
)
# On page load: check localStorage, skip gate if already authenticated
CHECK_AUTH_JS = """
() => {
try {
return [localStorage.getItem('oed_authed') === 'yes' ? 'yes' : 'no'];
} catch (e) {
return ['no'];
}
}
"""
demo.load(
fn=initial_auth_check,
inputs=[js_signal],
outputs=[login_gate, main_app],
js=CHECK_AUTH_JS,
)
if __name__ == "__main__":
demo.queue().launch(
server_name="0.0.0.0",
server_port=7860,
show_api=False,
)