""" MedVision Edge — Offline Chest X-ray Analysis Gradio demo for Gemma 4 E4B fine-tuned on NIH ChestX-ray14. Deploy: HuggingFace Space (ZeroGPU) or local with `python app.py` """ import os import re import json import torch import gradio as gr import spaces from PIL import Image from pathlib import Path from transformers import AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig # ── Config ────────────────────────────────────────────────────── MODEL_PATH = os.environ.get( "MEDVISION_MODEL", os.path.expanduser("~/ml-projects/medvision/model_output/final_model"), ) IS_SPACES = os.environ.get("SPACE_ID") is not None LOAD_IN_4BIT = os.environ.get("MEDVISION_4BIT", "true").lower() == "true" and not IS_SPACES PATHOLOGIES = ["Pneumonia", "Consolidation", "Cardiomegaly", "Effusion", "Edema"] LANGUAGES = { "English": "en", "Spanish": "es", "French": "fr", "Swahili": "sw", "Hindi": "hi", "Arabic": "ar", "Portuguese": "pt", "Bengali": "bn", "Chinese": "zh", "Russian": "ru", } EVAL_PROMPT = ( "Analyze this chest X-ray. For each condition, state YES or NO, " "then describe your findings.\n" "- Pneumonia\n- Consolidation\n- Cardiomegaly\n- Effusion\n- Edema" ) TRANSLATE_PROMPT = ( "Translate the following medical report to {language}. " "Keep medical terminology accurate. Translate only, do not add commentary.\n\n{text}" ) # ── Model loading ───────────────────────────────────────────── # ZeroGPU: load on CPU at module level, ZeroGPU moves to GPU automatically # Local: load with device_map="auto" (optionally 4-bit) print(f"Loading model from {MODEL_PATH} (spaces={IS_SPACES}, 4bit={LOAD_IN_4BIT})...") if LOAD_IN_4BIT: model = AutoModelForImageTextToText.from_pretrained( MODEL_PATH, quantization_config=BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_quant_type="nf4", ), device_map="auto", ) else: model = AutoModelForImageTextToText.from_pretrained( MODEL_PATH, dtype=torch.float16, ) model.eval() processor = AutoProcessor.from_pretrained(MODEL_PATH) print("Model loaded.") def parse_response(response: str) -> dict: """Extract YES/NO predictions from model response.""" predictions = {} clean = response.replace("**", "").replace("__", "").replace("*", "").replace("_", "") for pathology in PATHOLOGIES: p1 = rf"(?:[-•*]\s*)?(?:\d+\.\s*)?{pathology}\s*[:—–-]\s*(YES|NO|PRESENT|ABSENT)\b" p2 = rf"{pathology}\s*\(\s*(YES|NO|PRESENT|ABSENT)\s*\)" p3 = rf"{pathology}.{{0,60}}?\b(YES|NO|PRESENT|ABSENT)\b" p4_pos = rf"{pathology}.{{0,80}}?(present|detected|observed|found|identified|consistent with|indicative|suggesting|evidence of)" p4_neg = rf"{pathology}.{{0,80}}?(absent|not present|no evidence|not detected|not observed|unremarkable|clear|normal)" p4_neg_rev = rf"(no evidence of|no |absent|not |unremarkable|clear ).{{0,40}}?{pathology}" matched = False for pattern in [p1, p2, p3]: match = re.search(pattern, clean, re.IGNORECASE) if match: val = match.group(1).upper() predictions[pathology] = val in ("YES", "PRESENT") matched = True break if not matched: if re.search(p4_pos, clean, re.IGNORECASE): predictions[pathology] = True elif re.search(p4_neg, clean, re.IGNORECASE) or re.search(p4_neg_rev, clean, re.IGNORECASE): predictions[pathology] = False else: predictions[pathology] = False # Default to negative return predictions def run_inference(image: Image.Image, prompt: str) -> str: """Run model inference on a single image.""" messages = [ { "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image", "image": image}, ], }, ] text = processor.apply_chat_template(messages, add_generation_prompt=True) inputs = processor( text=[text], images=[image], return_tensors="pt", ).to(model.device) print(f"Inference on device: {model.device}, dtype: {model.dtype}") with torch.no_grad(): output = model.generate( **inputs, max_new_tokens=512, temperature=0.3, top_p=0.9, do_sample=True, ) decoded = processor.batch_decode(output, skip_special_tokens=True)[0] print(f"Raw decoded (first 500 chars): {decoded[:500]}") # Extract only the assistant's response (handle multiple Gemma 4 marker formats) for marker in ["<|turn>model", "model", "model\n"]: if marker in decoded: decoded = decoded.split(marker)[-1] break for marker in ["", "", ""]: if marker in decoded: decoded = decoded.split(marker)[0] break return decoded.strip() @spaces.GPU def analyze_xray(image, language, patient_age, patient_weight): """Main analysis function for Gradio interface.""" if image is None: return "Please upload a chest X-ray image.", "{}", "", "" # Move model to GPU if not already there (ZeroGPU assigns GPU when entering @spaces.GPU) if IS_SPACES and not next(model.parameters()).is_cuda: model.to("cuda") print(f"Model moved to cuda (first request)") elif IS_SPACES: print(f"Model already on cuda (cached)") # Convert to PIL if needed if not isinstance(image, Image.Image): image = Image.fromarray(image).convert("RGB") else: image = image.convert("RGB") # Resize large images (phone photos can be 12MP+) MAX_DIM = 1024 w, h = image.size if max(w, h) > MAX_DIM: scale = MAX_DIM / max(w, h) image = image.resize((round(w * scale), round(h * scale)), Image.LANCZOS) print(f"Image resized: {w}x{h} -> {image.size[0]}x{image.size[1]}") # 1. Run X-ray analysis raw_response = run_inference(image, EVAL_PROMPT) # 2. Parse findings findings = parse_response(raw_response) detected = [p for p, v in findings.items() if v] # 3. Generate findings summary findings_display = {} for p in PATHOLOGIES: status = "DETECTED" if findings.get(p) else "Not detected" emoji = "🔴" if findings.get(p) else "🟢" findings_display[f"{emoji} {p}"] = status # 4. Generate clinical protocol from src.protocols import generate_clinical_summary age = int(patient_age) if patient_age else None weight = float(patient_weight) if patient_weight else None clinical_summary = generate_clinical_summary(findings, age, weight) # 5. Translate if needed lang_name = language if language else "English" lang_code = LANGUAGES.get(lang_name, "en") translated = "" if lang_code != "en" and detected: translate_prompt = TRANSLATE_PROMPT.format( language=lang_name, text=clinical_summary, ) # Use the model to translate (Gemma 4 supports 140+ languages natively) messages = [ {"role": "user", "content": [{"type": "text", "text": translate_prompt}]}, ] text = processor.apply_chat_template(messages, add_generation_prompt=True) inputs = processor(text=[text], return_tensors="pt").to(model.device) with torch.no_grad(): output = model.generate(**inputs, max_new_tokens=1024, temperature=0.3, do_sample=True) translated = processor.batch_decode(output, skip_special_tokens=True)[0] # Strip model response from full output (handles Gemma 4 turn markers) # Try multiple marker formats: Gemma 4 uses <|turn>model / for marker in ["<|turn>model", "model", "model\n"]: if marker in translated: translated = translated.split(marker)[-1] break for marker in ["", "", ""]: if marker in translated: translated = translated.split(marker)[0] break # Fallback: if prompt text leaked through, strip it if translate_prompt in translated: translated = translated.split(translate_prompt)[-1] translated = translated.strip() findings_text = "\n".join(f"{k}: {v}" for k, v in findings_display.items()) return raw_response, findings_text, clinical_summary, translated # ── Gradio UI ─────────────────────────────────────────────────── DESCRIPTION = """ # MedVision Edge — Offline Chest X-ray Analysis **AI-powered chest X-ray screening** using Gemma 4 E4B fine-tuned on NIH ChestX-ray14 (~23K training samples with 5x oversampling). Detects 5 pathologies: **Pneumonia, Consolidation, Cardiomegaly, Pleural Effusion, Pulmonary Edema** ### Validated on two independent benchmarks | Pathology | Base AUC | Fine-tuned AUC | CheXpert (Gold Std) | Δ vs Base | |-----------|----------|----------------|---------------------|-----------| | Cardiomegaly | 0.490 | **0.832** | 0.723 | +70% | | Pleural Effusion | 0.605 | 0.703 | **0.797** | +16% | | Pulmonary Edema | 0.688 | **0.753** | 0.668 | +9% | | Consolidation | 0.599 | 0.627 | **0.667** | +5% | | Pneumonia | 0.519 | **0.617** | 0.501* | +19% | *Base AUC: unmodified Gemma 4 (zero-shot). Fine-tuned AUC: our model, evaluated on 1,103 held-out NIH images. CheXpert: same model evaluated on 500 independent images with 5-radiologist consensus labels (Stanford).* *\\*Pneumonia: insufficient CheXpert prevalence (2.2%). Detection under active development.* > **AI screening tool only.** Not for clinical diagnosis. All findings must be confirmed by a qualified radiologist. """ ARTICLE = """ ### About MedVision Edge - **Model**: Gemma 4 E4B-it, fine-tuned with Unsloth QLoRA (r=64, 82M trainable params) - **Training data**: NIH ChestX-ray14 (112,120 image dataset), ~23K training samples with 5x oversampling and augmentation - **Evaluation**: NIH test set (1,103 images) + CheXpert gold standard (500 images, 5 radiologist consensus) - **Protocols**: WHO IMCI 2024 clinical guidelines (deterministic function calling, zero hallucination) - **Languages**: 140+ supported natively by Gemma 4 - **Deployment**: Runs offline on consumer GPU (5GB GGUF via Ollama) or this Gradio demo - **GPU used**: ~43h on NVIDIA RTX 5070 Ti 16GB (training + evaluation) Born at the [Gemma 4 Good Hackathon](https://kaggle.com/competitions/gemma-4-good) | Apache 2.0 """ CUSTOM_CSS = """ /* Replace broken Gradio 6.x spinner SVG with a CSS spinner */ .wrap svg.svelte-1vhirvf { display: none !important; } .wrap.svelte-1uj8rng:not(.hide)::after { content: ""; width: 28px; height: 28px; border: 3px solid var(--body-text-color-subdued, #ccc); border-top-color: var(--color-accent, #FF7C00); border-radius: 50%; animation: medvision-spin 0.8s linear infinite; } @keyframes medvision-spin { to { transform: rotate(360deg); } } /* Mobile: horizontal scroll for markdown tables */ @media (max-width: 768px) { table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } table th, table td { white-space: nowrap; font-size: 0.78em; padding: 4px 6px !important; } } /* Responsive expected results cards */ .expected-results .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin: 8px 0; } .expected-results .card { background: var(--block-background-fill); border: 1px solid var(--block-border-color); border-radius: 8px; padding: 10px; font-size: 0.85em; } .expected-results .card strong { display: block; margin-bottom: 4px; } """ EXTRA_HEAD = """ """ demo = gr.Blocks( title="MedVision Edge", theme=gr.themes.Soft(), css=CUSTOM_CSS, head='' + EXTRA_HEAD, ) with demo: gr.Markdown(DESCRIPTION) with gr.Row(): with gr.Column(scale=1): image_input = gr.Image( type="pil", label="Upload Chest X-ray", sources=["upload", "webcam"], elem_id="xray-input", webcam_options=gr.WebcamOptions( mirror=False, constraints={"facingMode": {"ideal": "environment"}}, ), ) language_input = gr.Dropdown( choices=list(LANGUAGES.keys()), value="English", label="Output Language", ) with gr.Row(): age_input = gr.Number(label="Patient Age (years)", precision=0, minimum=0, maximum=120) weight_input = gr.Number(label="Patient Weight (kg)", precision=1, minimum=0, maximum=300) analyze_btn = gr.Button("🔍 Analyze X-ray", variant="primary", size="lg") with gr.Column(scale=2): findings_output = gr.Textbox(label="Findings", lines=6) raw_output = gr.Textbox(label="Model Analysis (raw)", lines=10) protocol_output = gr.Markdown(label="Clinical Protocol (WHO IMCI)") translated_output = gr.Markdown(label="Translated Report") analyze_btn.click( fn=analyze_xray, inputs=[image_input, language_input, age_input, weight_input], outputs=[raw_output, findings_output, protocol_output, translated_output], ) gr.HTML("""

Example chest X-rays — Expected results

CheXpert test set, radiologist-verified

1. NormalAll clear — no pathology detected
2. CardiomegalyCardiomegaly: DETECTED
3. EffusionEffusion: DETECTED
4. EdemaEdema: DETECTED
5. MultiplePneumonia: DETECTED
Consolidation: DETECTED
Effusion: DETECTED
""") gr.Examples( examples=[ ["examples/example1_normal.jpg", "English", 35, 70], ["examples/example2_cardiomegaly.jpg", "English", 62, 85], ["examples/example3_effusion.jpg", "English", 55, 75], ["examples/example4_edema.jpg", "Spanish", 70, 80], ["examples/example5_multiple.jpg", "English", 58, 72], ], inputs=[image_input, language_input, age_input, weight_input], label="Click an example to load it", ) gr.Markdown(ARTICLE) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, share=False)