import os
import io
import base64
import hashlib
import numpy as np
import gradio as gr
from PIL import Image
from datetime import datetime
import tensorflow as tf
import model_setup
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
CLASS_LABELS = ["COVID-19", "Normal", "Viral Pneumonia", "Lung Opacity"]
CLASS_COLORS = {
"COVID-19": "#ff5f5f",
"Normal": "#3dd68c",
"Viral Pneumonia": "#ffaa44",
"Lung Opacity": "#4fa3f7",
}
CLASS_TAGS = {
"COVID-19": "CRITICAL",
"Normal": "CLEAR",
"Viral Pneumonia": "ABNORMAL",
"Lung Opacity": "ABNORMAL",
}
CLASS_DESCRIPTIONS = {
"COVID-19":
"Findings consistent with COVID-19 pneumonia. Bilateral, peripheral, "
"and basal-predominant ground-glass opacities are characteristic. "
"Clinical correlation and RT-PCR confirmation recommended.",
"Normal":
"No significant pathological findings detected. Lung fields are clear "
"with no evidence of consolidation, effusion, or interstitial changes. "
"Standard follow-up as clinically indicated.",
"Viral Pneumonia":
"Findings suggestive of viral pneumonia. Diffuse bilateral interstitial "
"infiltrates with ground-glass opacity pattern. Further evaluation with "
"HRCT and laboratory correlation is advised.",
"Lung Opacity":
"Area(s) of increased opacity detected. Differential includes consolidation, "
"atelectasis, pleural effusion, or mass lesion. CT evaluation recommended "
"for further characterisation.",
}
IMG_SIZE = (128, 128)
print("Loading model…")
model = tf.keras.models.load_model(model_setup.paths["cnn_model_lung_detection.keras"])
print("Model ready.")
_bg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"attached_assets", "image_1779746772720.png")
_buf = io.BytesIO()
_pil = Image.open(_bg_path)
_pil.resize((1400, int(_pil.height * 1400 / _pil.width)), Image.LANCZOS).convert("RGB").save(
_buf, format="JPEG", quality=84)
BG_URI = f"data:image/jpeg;base64,{base64.b64encode(_buf.getvalue()).decode()}"
print("Background loaded.")
def preprocess(image: Image.Image) -> np.ndarray:
arr = np.array(image.convert("RGB").resize(IMG_SIZE), dtype=np.float32) / 255.0
return np.expand_dims(arr, axis=0)
PLACEHOLDER = """
System Status
Detection Classes
Upload a chest radiograph on the left, then press
Run Analysis
to generate a structured diagnostic report.
"""
def predict(image: Image.Image):
if image is None:
return gr.update(value=PLACEHOLDER), gr.update(visible=False)
probs = model.predict(preprocess(image), verbose=0)[0]
idx = int(np.argmax(probs))
label = CLASS_LABELS[idx]
conf = float(probs[idx]) * 100
color = CLASS_COLORS[label]
desc = CLASS_DESCRIPTIONS[label]
tag = CLASS_TAGS[label]
scan_id = hashlib.md5(image.tobytes()[:256]).hexdigest()[:8].upper()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
dims = f"{image.width} × {image.height}"
# SVG confidence ring (circumference of r=28: 2π×28 ≈ 175.9)
arc = conf / 100 * 175.9
ring_bg = "#162030"
ring_svg = f"""
{conf:.0f}%
"""
# Probability bars
bars = ""
for i, (cls, prob) in enumerate(zip(CLASS_LABELS, probs)):
pct = float(prob) * 100
clr = CLASS_COLORS[cls]
w = f"{pct:.1f}"
is_primary = (i == idx)
glow = f"box-shadow:0 0 8px {clr}55;" if is_primary else ""
alpha = "ff" if is_primary else "66"
bars += f"""
"""
html = f"""
Scan ID
{scan_id}
Date/Time
{timestamp}
Dimensions
{dims} px
Modality
CXR
{tag}
Primary Finding
{ring_svg}
{label}
Model confidence: {conf:.2f}%
Clinical Impression
{desc}
Differential Diagnosis
{bars}
⚠️ FOR RESEARCH AND EDUCATIONAL USE ONLY. This AI output is not a clinical diagnosis and must not be used as a substitute for professional radiological assessment.
"""
return gr.update(value=html), gr.update(visible=True)
def _badge_rgb(hex_color: str) -> str:
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"{r},{g},{b}"
css = f"""
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
/* ═══════════════════════════════════════════════════════════════════════
RESET
═══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
/* ═══════════════════════════════════════════════════════════════════════
PAGE BACKGROUND — medical lab photo, zoomed to screen area
═══════════════════════════════════════════════════════════════════════ */
html, body {{ height: 100%; }}
body {{
font-family: 'Inter', system-ui, sans-serif;
background-image: url('{BG_URI}');
background-size: 190% auto;
background-position: 6% 28%;
background-attachment: fixed;
background-repeat: no-repeat;
background-color: #ffffff;
}}
/* No overlay — clean white background with dark cards */
/* ═══════════════════════════════════════════════════════════════════════
GRADIO CHROME STRIP
═══════════════════════════════════════════════════════════════════════ */
.gradio-container {{
position: relative !important;
z-index: 1 !important;
max-width: 1000px !important;
width: 100% !important;
margin: 0 auto !important;
padding: 24px 16px 40px !important;
background: #ffffff !important;
}}
/* Remove all default Gradio block chrome */
.gradio-container .block,
.gradio-container .form,
.gradio-container .gap,
.gradio-container .padded,
.gradio-container .contain {{
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
gap: 0 !important;
}}
footer, .built-with, #footer {{ display: none !important; }}
/* ═══════════════════════════════════════════════════════════════════════
SYSTEM HEADER
═══════════════════════════════════════════════════════════════════════ */
.sys-header {{
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(2, 6, 16, 0.98);
border: 1px solid rgba(50, 120, 210, 0.40);
border-bottom: 1px solid rgba(30, 90, 160, 0.25);
border-radius: 8px 8px 0 0;
padding: 0 24px;
height: 64px;
box-shadow:
inset 0 2px 0 rgba(60, 140, 240, 0.60),
0 -1px 20px rgba(30, 80, 180, 0.10);
}}
.sys-title {{
font-size: 12px;
font-weight: 600;
letter-spacing: 0.24em;
color: #98c8e8;
text-transform: uppercase;
line-height: 1;
margin-bottom: 7px;
}}
.sys-subtitle {{
font-size: 9px;
font-weight: 400;
letter-spacing: 0.16em;
color: #567a92;
text-transform: uppercase;
}}
.sys-indicators {{
display: flex;
align-items: center;
gap: 20px;
}}
.sys-dot {{
display: flex;
align-items: center;
gap: 7px;
font-size: 9px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6898b0;
}}
.sys-dot::before {{
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}}
.sys-dot.ready::before {{ background: #3dd68c; box-shadow: 0 0 8px #3dd68caa; }}
.sys-dot.cpu::before {{ background: #4fa3f7; box-shadow: 0 0 8px #4fa3f788; }}
/* ═══════════════════════════════════════════════════════════════════════
MAIN PANEL — two columns
═══════════════════════════════════════════════════════════════════════ */
.main-row {{
display: flex !important;
gap: 0 !important;
background: #0a1220;
border: 1px solid rgba(50, 120, 210, 0.40);
border-top: none;
border-radius: 0 0 8px 8px;
overflow: hidden;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.55),
0 0 100px rgba(20, 60, 160, 0.06);
}}
/* Ensure Gradio's row is a flex container */
.gradio-container .main-row > .row,
.gradio-container .row {{
display: flex !important;
flex-wrap: nowrap !important;
gap: 0 !important;
background: transparent !important;
}}
/* Columns */
.left-col, .right-col {{
flex: 1 !important;
min-width: 0 !important;
padding: 24px 26px !important;
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
}}
.left-col {{
border-right: 1px solid rgba(40, 100, 190, 0.18) !important;
}}
/* ═══════════════════════════════════════════════════════════════════════
COLUMN HEADING
═══════════════════════════════════════════════════════════════════════ */
.col-head {{
font-size: 9px;
font-weight: 600;
letter-spacing: 0.24em;
color: #6a9ec0;
text-transform: uppercase;
padding-bottom: 12px;
border-bottom: 1px solid rgba(50, 110, 190, 0.22);
margin-bottom: 18px;
}}
/* ═══════════════════════════════════════════════════════════════════════
IMAGE UPLOAD ZONE
Target broadly since Gradio 6.x class names can vary
═══════════════════════════════════════════════════════════════════════ */
.upload-zone {{
margin-bottom: 14px !important;
flex-shrink: 0 !important;
}}
/* The inner image container */
.upload-zone .image-container,
.upload-zone .upload-area,
.upload-zone [data-testid="image"],
.upload-zone .wrap {{
border: 1px dashed rgba(50, 110, 200, 0.50) !important;
border-radius: 6px !important;
background: rgba(2, 6, 16, 0.88) !important;
min-height: 220px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: border-color 0.2s ease, background 0.2s ease !important;
}}
.upload-zone .wrap:hover,
.upload-zone .image-container:hover {{
border-color: rgba(70, 150, 240, 0.60) !important;
background: rgba(2, 6, 18, 0.92) !important;
}}
/* Upload text / icon tones */
.upload-zone .wrap > p,
.upload-zone .upload-text,
.upload-zone .or {{
color: #7aaacc !important;
font-family: 'Inter', sans-serif !important;
font-size: 12px !important;
font-weight: 300 !important;
letter-spacing: 0.06em !important;
}}
.upload-zone svg {{ color: #3c6088 !important; opacity: 0.90 !important; }}
/* Toolbar below image */
.upload-zone .toolbar {{
background: rgba(2, 5, 14, 0.88) !important;
border: none !important;
border-top: 1px solid rgba(40, 90, 160, 0.18) !important;
}}
.upload-zone .toolbar button {{
color: #4a7090 !important;
background: transparent !important;
border: none !important;
}}
/* ═══════════════════════════════════════════════════════════════════════
UPLOAD METADATA STRIP
═══════════════════════════════════════════════════════════════════════ */
.upload-meta {{
padding: 12px 16px;
background: rgba(2, 6, 16, 0.88);
border: 1px solid rgba(40, 90, 160, 0.30);
border-radius: 6px;
margin-bottom: 14px;
}}
.meta-strip-row {{
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 10px;
font-weight: 300;
letter-spacing: 0.05em;
color: #8ab8d0;
line-height: 1.5;
}}
.meta-strip-row:last-child {{ margin-bottom: 0; }}
.meta-strip-dot {{
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4px;
}}
.meta-strip-key {{
font-size: 9px;
font-weight: 600;
letter-spacing: 0.16em;
color: #4e7292;
text-transform: uppercase;
flex-shrink: 0;
padding-top: 1px;
min-width: 68px;
}}
/* ═══════════════════════════════════════════════════════════════════════
ANALYZE BUTTON — secondary variant so custom CSS always wins
═══════════════════════════════════════════════════════════════════════ */
.gradio-container .analyze-btn button,
.gradio-container .analyze-btn > button,
.gradio-container .analyze-btn * button,
.gradio-container .analyze-btn button:focus {{
width: 100% !important;
min-height: 46px !important;
background: #0d2a50 !important;
border: 1px solid #3a80cc !important;
color: #e0f0ff !important;
font-family: 'Inter', sans-serif !important;
font-size: 10px !important;
font-weight: 600 !important;
letter-spacing: 0.22em !important;
text-transform: uppercase !important;
border-radius: 5px !important;
padding: 13px 0 !important;
transition: all 0.20s ease !important;
cursor: pointer !important;
box-shadow: none !important;
opacity: 1 !important;
text-shadow: none !important;
filter: none !important;
}}
.gradio-container .analyze-btn button:hover,
.gradio-container .analyze-btn button:active {{
background: #113260 !important;
border-color: #4a9cee !important;
color: #f0f8ff !important;
box-shadow: 0 0 24px rgba(50, 140, 230, 0.22) !important;
opacity: 1 !important;
}}
/* ═══════════════════════════════════════════════════════════════════════
CLEAR BUTTON — catches ALL Gradio secondary buttons
═══════════════════════════════════════════════════════════════════════ */
.clear-btn button,
.clear-btn button:focus,
.gradio-container button.secondary,
.gradio-container button[class*="secondary"],
.gradio-container button[style*="background"] {{
width: 100% !important;
background: transparent !important;
border: 1px solid rgba(60, 100, 150, 0.50) !important;
color: #7aaccc !important;
font-family: 'Inter', sans-serif !important;
font-size: 9.5px !important;
font-weight: 400 !important;
letter-spacing: 0.16em !important;
text-transform: uppercase !important;
border-radius: 5px !important;
padding: 9px 0 !important;
margin-top: 10px !important;
transition: all 0.15s ease !important;
cursor: pointer !important;
}}
.clear-btn button:hover,
.gradio-container button.secondary:hover {{
border-color: rgba(80, 130, 190, 0.60) !important;
color: #80a8c8 !important;
background: rgba(20, 40, 70, 0.30) !important;
}}
/* ═══════════════════════════════════════════════════════════════════════
PLACEHOLDER STATE
═══════════════════════════════════════════════════════════════════════ */
.placeholder-wrap {{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 320px;
text-align: center;
padding: 24px;
border: 1px dashed rgba(40, 90, 160, 0.22);
border-radius: 6px;
background: rgba(2, 5, 14, 0.70);
}}
.placeholder-icon {{
font-size: 28px;
color: #203a54;
margin-bottom: 16px;
line-height: 1;
}}
.placeholder-title {{
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
color: #5a8aaa;
text-transform: uppercase;
margin-bottom: 12px;
}}
.placeholder-sub {{
font-size: 11.5px;
font-weight: 300;
color: #5a7a96;
line-height: 1.85;
max-width: 260px;
}}
.placeholder-sub em {{
color: #7aaccc;
font-style: normal;
font-weight: 500;
}}
/* ═══════════════════════════════════════════════════════════════════════
ANALYSIS REPORT
═══════════════════════════════════════════════════════════════════════ */
.report {{ font-family: 'Inter', sans-serif; }}
/* Report header: scan metadata + badge */
.report-head {{
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(40, 90, 160, 0.22);
}}
.report-meta {{ display: flex; flex-direction: column; gap: 5px; }}
.meta-row {{
display: flex;
gap: 10px;
align-items: baseline;
}}
.meta-key {{
font-size: 8px;
font-weight: 600;
letter-spacing: 0.18em;
color: #5a8aaa;
text-transform: uppercase;
width: 70px;
flex-shrink: 0;
}}
.meta-val {{
font-size: 10px;
font-weight: 400;
color: #8ec0e0;
letter-spacing: 0.04em;
}}
.meta-val.mono {{
font-family: 'JetBrains Mono', monospace;
font-size: 9.5px;
color: #90c8e0;
letter-spacing: 0.05em;
}}
.report-badge {{
font-size: 8.5px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: 3px;
border: 1px solid;
flex-shrink: 0;
}}
/* Section heading */
.section-label {{
font-size: 8px;
font-weight: 600;
letter-spacing: 0.22em;
color: #5a8aaa;
text-transform: uppercase;
margin-bottom: 10px;
margin-top: 20px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(50, 100, 180, 0.22);
}}
.section-label:first-of-type {{ margin-top: 0; }}
/* Primary finding card */
.finding-card {{
display: flex;
align-items: center;
gap: 20px;
padding: 16px 18px;
background: rgba(2, 6, 18, 0.88);
border: 1px solid rgba(50, 110, 200, 0.28);
border-radius: 6px;
}}
.finding-left {{ flex-shrink: 0; }}
.finding-right {{ flex: 1; min-width: 0; }}
.finding-name {{
font-size: 22px;
font-weight: 300;
letter-spacing: 0.06em;
line-height: 1.1;
margin-bottom: 7px;
}}
.finding-conf {{
font-size: 11px;
font-weight: 300;
color: #6a90ac;
letter-spacing: 0.04em;
}}
.finding-conf .mono {{
font-family: 'JetBrains Mono', monospace !important;
font-size: 10.5px;
font-weight: 500;
}}
/* Clinical impression */
.clinical-text {{
font-size: 11.5px;
font-weight: 300;
color: #a0c8e0;
line-height: 1.82;
letter-spacing: 0.025em;
}}
/* Differential table */
.diff-table {{ display: flex; flex-direction: column; gap: 10px; }}
.diff-row {{
display: grid;
grid-template-columns: 136px 1fr 52px;
align-items: center;
gap: 12px;
}}
.diff-name {{
font-size: 10.5px;
font-weight: 300;
color: #6a8aaa;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.diff-primary .diff-name {{
color: #98c0d8 !important;
font-weight: 500 !important;
}}
.diff-bar-wrap {{
height: 4px;
background: rgba(12, 28, 55, 0.95);
border-radius: 2px;
overflow: hidden;
}}
.diff-bar {{
height: 100%;
border-radius: 2px;
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}}
.diff-pct {{
font-family: 'JetBrains Mono', monospace;
font-size: 9.5px;
font-weight: 500;
text-align: right;
white-space: nowrap;
}}
.pct-unit {{ font-size: 7.5px; opacity: 0.65; }}
/* Disclaimer */
.report-footer {{
margin-top: 20px;
padding: 10px 14px;
background: rgba(25, 16, 4, 0.55);
border: 1px solid rgba(120, 80, 20, 0.32);
border-radius: 4px;
font-size: 9.5px;
font-weight: 300;
color: #907040;
line-height: 1.65;
letter-spacing: 0.025em;
}}
/* ═══════════════════════════════════════════════════════════════════════
GRADIO LABELS — hide default labels (we handle our own)
═══════════════════════════════════════════════════════════════════════ */
.gradio-container label > span,
.gradio-container .label-wrap span {{ display: none !important; }}
/* ═══════════════════════════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════════════════════════ */
::-webkit-scrollbar {{ width: 3px; }}
::-webkit-scrollbar-track {{ background: transparent; }}
::-webkit-scrollbar-thumb {{ background: rgba(50, 100, 160, 0.28); border-radius: 2px; }}
"""
with gr.Blocks() as demo:
# ── System header ─────────────────────────────────────────────────────
gr.HTML("""
""")
# ── Main row: two columns ─────────────────────────────────────────────
with gr.Row(elem_classes=["main-row"], equal_height=True):
# Left column: upload
with gr.Column(elem_classes=["left-col"], scale=1):
gr.HTML('Input — Chest Radiograph
')
image_input = gr.Image(
type="pil",
label="Upload",
show_label=False,
elem_classes=["upload-zone"],
height=240,
)
gr.HTML("""
""")
analyze_btn = gr.Button(
"Run Analysis",
variant="secondary",
elem_classes=["analyze-btn"],
)
# Right column: results
with gr.Column(elem_classes=["right-col"], scale=1):
gr.HTML('Analysis Report
')
result_display = gr.HTML(value=PLACEHOLDER)
clear_btn = gr.Button(
"Clear Study",
variant="secondary",
elem_classes=["clear-btn"],
visible=False,
)
# ── Event handlers ────────────────────────────────────────────────────
analyze_btn.click(
fn=predict,
inputs=[image_input],
outputs=[result_display, clear_btn],
)
def clear():
return None, gr.update(value=PLACEHOLDER), gr.update(visible=False)
clear_btn.click(fn=clear, inputs=[], outputs=[image_input, result_display, clear_btn])
_port = int(os.environ.get("PORT", 7860))
_dev_domain = os.environ.get("REPLIT_DEV_DOMAIN", "")
_root_path = f"https://{_dev_domain}:{_port}" if _dev_domain else ""
demo.launch(
server_name="0.0.0.0",
server_port=_port,
root_path=_root_path,
css=css,
theme=gr.themes.Base(),
strict_cors=False,
)