""" 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}
{top['Score (%)']:.1f}%
📊 ترتيب كل المجموعات
{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:" "" "ثم اضغط 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 = ( "الأسباب المحتملة:" "" ) 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 = """

🔬 تشخيص سوء التصنع الفموي بالذكاء الاصطناعي

تحليل الصور النسيجية بنموذج PLIP المتخصص في الباثولوجيا الرقمية

OED • PLIP • Zero-Shot
""" 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, )