pneumoops / frontend /app.py
Prakhar54-byte's picture
Deploy build-8b7ed87
bdd5f1a verified
Raw
History Blame Contribute Delete
13.3 kB
import io
import os
import requests
import gradio as gr
from PIL import Image
PORT = os.getenv("PORT", "7860")
BACKEND_URL = os.getenv("BACKEND_PREDICT_URL", f"http://127.0.0.1:{PORT}/predict")
def call_backend(image: Image.Image, api_url: str) -> dict:
buf = io.BytesIO()
image.convert("RGB").save(buf, format="PNG")
buf.seek(0)
resp = requests.post(
api_url,
files={"file": ("xray.png", buf.getvalue(), "image/png")},
timeout=60,
)
resp.raise_for_status()
return resp.json()
def render_top_metrics(payload: dict) -> tuple[str, str]:
model_arm = payload.get("selected_model", "Unknown")
latency_ms = max(payload.get("latency_ms", 0), 0)
latency_html = f'<div class="metric-value">{latency_ms} ms</div>'
if latency_ms < 0:
latency_html = f'''
<div class="metric-value" style="color: #ef4444;">{latency_ms} ms</div>
<div style="background: #452424; border-left: 4px solid #ef4444; padding: 8px 12px; border-radius: 4px; margin-top: 8px;">
<div style="color:#fca5a5;font-weight:600;font-size:0.85rem;">Bug: negative latency</div>
<div style="color:#fecaca;font-size:0.8rem;">Timing value is invalid — likely a clock sync issue.<br>Fix the timer before showing this to patients.</div>
</div>
'''
arm_html = f'''
<div class="custom-card">
<div class="metric-title">MODEL (A/B ARM)</div>
<div class="metric-value">{model_arm}</div>
</div>
'''
lat_html = f'''
<div class="custom-card">
<div class="metric-title">INFERENCE LATENCY</div>
{latency_html}
</div>
'''
return arm_html, lat_html
def render_quality(payload: dict) -> str:
drift = payload.get("drift", {})
alert = drift.get("drift_alert", "NORMAL")
if alert == "DRIFT_DETECTED":
return '''
<div class="custom-card" style="border-left: 4px solid #ef4444;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="metric-title">INPUT IMAGE QUALITY CHECK</div>
<div style="background:#452424; color:#fca5a5; padding:4px 10px; border-radius:12px; font-size:0.75rem;">⚠ Out of Distribution</div>
</div>
<div style="font-size:1.1rem; font-weight:500; margin-top:4px;">Distribution differs from training baseline</div>
<div style="background:#452424; padding:12px; border-radius:6px; margin-top:12px;">
<div style="color:#fbbf24; font-weight:600; font-size:0.9rem; margin-bottom:4px;">What this means for the patient</div>
<div style="color:#d1d5db; font-size:0.85rem;">The model has never seen images like this. Predictions may be unreliable. Proceed with extreme caution.</div>
</div>
</div>
'''
return '''
<div class="custom-card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="metric-title">INPUT IMAGE QUALITY CHECK</div>
<div style="background:#064e3b; color:#34d399; padding:4px 10px; border-radius:12px; font-size:0.75rem;">✓ Acceptable</div>
</div>
<div style="font-size:1.1rem; font-weight:500; margin-top:4px; margin-bottom:12px;">Distribution matches training baseline</div>
<div style="background:#453015; padding:12px; border-radius:6px; margin-top:8px; border-left: 4px solid #f59e0b;">
<div style="color:#fbbf24; font-weight:600; font-size:0.9rem; margin-bottom:4px;">What this means for the patient</div>
<div style="color:#d1d5db; font-size:0.85rem; line-height:1.4;">This <i>does not</i> mean your lungs are normal. It only means the uploaded X-ray image is technically readable by the model — the contrast, resolution, and framing look similar to images it was trained on.</div>
</div>
</div>
'''
_TIER_COLORS = {
"Pneumonia": "#ef4444", "Pneumothorax": "#ef4444", "Mass": "#ef4444", "Nodule": "#ef4444",
"Effusion": "#f59e0b", "Cardiomegaly": "#f59e0b", "Consolidation": "#f59e0b",
}
_TIER_DESC = {
"Pneumonia": "Infection inflaming air sacs, potentially filling with fluid.",
"Pneumothorax": "Collapsed lung — air leaked into the space between lung and chest wall.",
"Mass": "Larger opacity (>3cm). Not actionable at this score.",
"Nodule": "A small round opacity — may be benign or require follow-up.",
"Effusion": "Fluid build-up in the pleural space.",
"Cardiomegaly": "Enlarged heart — could indicate underlying heart conditions.",
"Consolidation": "Region of normally compressible lung tissue filled with liquid.",
"Infiltration": "Fluid or tissue density where there should be air — could suggest infection, inflammation, or early pneumonia.",
}
def render_top3(payload: dict) -> str:
top3 = payload.get("top_predictions", [])
inconclusive = payload.get("inconclusive_scan", False)
html = '<div class="custom-card">'
html += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">'
html += '<div class="metric-title">TOP-3 PREDICTED PATHOLOGIES</div>'
html += '<div style="font-size:0.8rem; color:#9ca3af;"><span style="color:#ef4444;">●</span> Critical &nbsp;&nbsp; <span style="color:#f59e0b;">●</span> Significant &nbsp;&nbsp; <span style="color:#3b82f6;">●</span> Standard</div>'
html += '</div>'
for p in top3:
lbl = p["label"]
conf = p["confidence"]
col = _TIER_COLORS.get(lbl, "#3b82f6")
desc = _TIER_DESC.get(lbl, "A generic chest anomaly flagged by the model.")
conf_text = f"Confidence is very low ({conf}%)." if conf < 10 else f"Confidence: {conf}%."
flagged = '<span style="background:#1e3a8a; color:#93c5fd; padding:4px 10px; border-radius:12px; font-size:0.75rem; margin-left:8px;">Flagged finding</span>' if p["detected"] else ''
html += f'''
<div style="background:#333333; padding:16px; border-radius:8px; margin-bottom:12px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<div style="font-size:1.1rem; font-weight:600;"><span style="color:{col}; font-size:1.2rem;">●</span> {lbl} {flagged}</div>
<div style="font-weight:600; font-size:1.2rem;">{conf}%</div>
</div>
<div style="width:100%; background:#4b5563; height:8px; border-radius:4px; overflow:hidden; margin-bottom:12px;">
<div style="width:{conf}%; background:{col}; height:100%;"></div>
</div>
<div style="font-size:0.85rem; color:#d1d5db;">{desc} <strong>{conf_text}</strong></div>
</div>
'''
if inconclusive:
html += '''
<div style="background:#452424; border:1px solid #7f1d1d; padding:16px; border-radius:8px; margin-top:16px;">
<div style="color:#fca5a5; font-weight:600; margin-bottom:4px;">All confidence scores are below 10% — interpret with extreme caution</div>
<div style="color:#fecaca; font-size:0.9rem; line-height:1.4;">Scores this low indicate the model is uncertain. A well-calibrated model should ideally show >50% before a finding is clinically noteworthy. These results should <i>not</i> drive any clinical decision without radiologist review.</div>
</div>
'''
html += '</div>'
return html
def render_14_classes(payload: dict) -> str:
all_preds = payload.get("all_predictions", [])
html = '<div class="custom-card">'
html += '<div class="metric-title" style="margin-bottom:12px;">ALL 14 SCREENED CONDITIONS</div>'
html += '<div style="display:flex; flex-wrap:wrap; gap:8px;">'
for p in all_preds:
lbl = p["label"]
conf = p["confidence"]
det = p["detected"]
if det:
bg, color = "#78350f", "#fcd34d" # Yellow highlight
else:
bg, color = "#374151", "#9ca3af" # Gray
text = f"{lbl} {conf}%" if det else lbl
html += f'<div style="background:{bg}; color:{color}; padding:6px 14px; border-radius:16px; font-size:0.85rem; font-weight:500; border:1px solid #4b5563;">{text}</div>'
html += '</div>'
html += '<div style="font-size:0.8rem; color:#6b7280; margin-top:16px;">Grey chips = below detection threshold. Not shown does not mean absent.</div>'
html += '</div>'
return html
def render_calibration_info() -> str:
"""Provides historical benchmark context as requested in the redesign proposal."""
return '''
<div class="custom-card">
<div class="metric-title">MODEL CALIBRATION & BENCHMARKING</div>
<div style="font-size:0.88rem; color:#d1d5db; line-height:1.6;">
Model calibrated on NIH ChestX-ray14. Historical AUC benchmarks:
<ul style="margin-top:8px; padding-left:18px; margin-bottom:8px;">
<li><b style="color:#34d399;">Cardiomegaly:</b> ~0.81 AUC (Highly reliable)</li>
<li><b style="color:#fbbf24;">Infiltration:</b> ~0.70 AUC (Historically lower reliability)</li>
<li><b style="color:#fbbf24;">Pneumonia:</b> ~0.68 AUC</li>
</ul>
<i>Note for Clinicians:</i> Predictions for "Infiltration" and "Pneumonia" carry higher uncertainty due to lower historical AUC. Radiologist verification is mandatory.
</div>
</div>
'''
def predict(image: Image.Image, api_url: str):
if image is None:
raise gr.Error("Please upload a chest X-ray image.")
try:
payload = call_backend(image, api_url)
except Exception as exc:
raise gr.Error(f"Backend error: {str(exc)}") from exc
arm_html, lat_html = render_top_metrics(payload)
qc_html = render_quality(payload)
top3_html = render_top3(payload)
classes_html = render_14_classes(payload)
calib_html = render_calibration_info()
ethics_html = """<div class="custom-card" style="border-top: 4px solid #ef4444; background: #450a0a;">
<div class="metric-title">ETHICAL & CLINICAL COMPLIANCE</div>
<div style="font-size:0.9rem; color:#fca5a5;">
<b>Data Privacy:</b> HIPAA-compliant processing. No PHI is stored.<br>
<b>Model Bias:</b> Regular audits for demographic parity across age and gender.<br>
<b>Clinical Intent:</b> Screening assistant only. Not for primary diagnosis.
</div>
</div>"""
summary_html = f'<div class="custom-card" style="border-left: 4px solid #3b82f6; background: #1e293b; padding: 15px; margin-bottom: 10px;"><div class="metric-title">CLINICAL SUMMARY</div><div style="font-size:1.1rem; font-weight:500; color:#f8fafc;">{payload.get("summary", "Screening complete.")}</div></div>'
return arm_html, lat_html, summary_html, ethics_html, qc_html, top3_html, classes_html, calib_html
CSS = """
body, .gradio-container {
background-color: #1e1e1e !important;
color: #e5e5e5 !important;
}
.custom-card {
background: #2b2b2b;
border-radius: 12px;
padding: 20px;
border: 1px solid #3f3f46;
margin-bottom: 16px;
}
.metric-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 6px;
font-weight: 600;
}
.metric-value {
font-size: 1.25rem;
font-weight: 500;
color: #f4f4f5;
}
"""
with gr.Blocks(theme=gr.themes.Base(), css=CSS, title="PneumoOps Redesigned") as demo:
with gr.Accordion("⚙️ Backend Settings", open=False):
api_url = gr.Textbox(label="Backend URL", value=BACKEND_URL)
with gr.Row():
with gr.Column(scale=1):
image_input = gr.Image(type="pil", label="Upload Chest X-Ray")
submit_btn = gr.Button("🔬 Run Screening", variant="primary", size="lg")
with gr.Column(scale=2):
with gr.Row():
arm_out = gr.HTML()
lat_out = gr.HTML()
summary_out = gr.HTML()
ethics_out = gr.HTML()
qc_out = gr.HTML()
top3_out = gr.HTML()
classes_out = gr.HTML()
calib_out = gr.HTML()
gr.HTML('''
<div class="custom-card">
<div style="font-weight:600; font-size:0.95rem; margin-bottom:4px; display:flex; align-items:center; gap:8px;">
⚖️ Responsible AI notice
</div>
<div style="color:#9ca3af; font-size:0.9rem; line-height:1.5;">
This system <i>assists in prediction</i>, it does not confirm diagnoses. Results are statistical estimates and must be reviewed by a qualified radiologist or clinician before any medical decision is made. Do not use these results to self-diagnose.
</div>
</div>
''')
submit_btn.click(
fn=predict,
inputs=[image_input, api_url],
outputs=[arm_out, lat_out, summary_out, ethics_out, qc_out, top3_out, classes_out, calib_out],
)
if __name__ == "__main__":
port = int(os.getenv("GRADIO_PORT", "7860"))
demo.launch(server_name="0.0.0.0", server_port=port)