lung-detect / app.py
drewinspirit's picture
Publish from Replit
9f1a43f verified
Raw
History Blame Contribute Delete
37.7 kB
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 = """
<div style="font-family:'Inter',sans-serif;">
<!-- System status grid -->
<div style="font-size:9px;font-weight:600;letter-spacing:0.22em;color:#6a9ec0;
text-transform:uppercase;padding-bottom:8px;margin-bottom:12px;
border-bottom:1px solid rgba(60,130,210,0.30);">System Status</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px;">
<div style="background:rgba(4,10,26,0.92);border:1px solid rgba(60,130,210,0.32);
border-radius:5px;padding:11px 14px;">
<div style="font-size:9px;color:#5a8aaa;font-weight:600;letter-spacing:0.16em;
text-transform:uppercase;margin-bottom:6px;">Model</div>
<div style="font-size:14px;color:#3dd68c;font-weight:300;letter-spacing:0.06em;">READY</div>
</div>
<div style="background:rgba(4,10,26,0.92);border:1px solid rgba(60,130,210,0.32);
border-radius:5px;padding:11px 14px;">
<div style="font-size:9px;color:#5a8aaa;font-weight:600;letter-spacing:0.16em;
text-transform:uppercase;margin-bottom:6px;">Mode</div>
<div style="font-size:14px;color:#4fa3f7;font-weight:300;letter-spacing:0.06em;">CPU</div>
</div>
<div style="background:rgba(4,10,26,0.92);border:1px solid rgba(60,130,210,0.32);
border-radius:5px;padding:11px 14px;">
<div style="font-size:9px;color:#5a8aaa;font-weight:600;letter-spacing:0.16em;
text-transform:uppercase;margin-bottom:6px;">Classes</div>
<div style="font-size:14px;color:#98c0d8;font-weight:300;
font-family:'JetBrains Mono','Courier New',monospace;">4</div>
</div>
<div style="background:rgba(4,10,26,0.92);border:1px solid rgba(60,130,210,0.32);
border-radius:5px;padding:11px 14px;">
<div style="font-size:9px;color:#5a8aaa;font-weight:600;letter-spacing:0.16em;
text-transform:uppercase;margin-bottom:6px;">Input Size</div>
<div style="font-size:14px;color:#98c0d8;font-weight:300;
font-family:'JetBrains Mono','Courier New',monospace;">128&times;128</div>
</div>
</div>
<!-- Detection classes -->
<div style="font-size:9px;font-weight:600;letter-spacing:0.22em;color:#6a9ec0;
text-transform:uppercase;padding-bottom:8px;margin-bottom:12px;
border-bottom:1px solid rgba(60,130,210,0.30);">Detection Classes</div>
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;
background:rgba(4,10,26,0.88);border-radius:4px;
border:1px solid rgba(255,95,95,0.25);">
<div style="width:6px;height:6px;border-radius:50%;background:#ff5f5f;
box-shadow:0 0 6px #ff5f5faa;flex-shrink:0;"></div>
<span style="font-size:11px;color:#c0d8f0;font-weight:300;letter-spacing:0.04em;">COVID-19</span>
<span style="margin-left:auto;font-size:9px;color:#5a7a96;letter-spacing:0.10em;font-weight:500;">CLASS 1</span>
</div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;
background:rgba(4,10,26,0.88);border-radius:4px;
border:1px solid rgba(61,214,140,0.25);">
<div style="width:6px;height:6px;border-radius:50%;background:#3dd68c;
box-shadow:0 0 6px #3dd68caa;flex-shrink:0;"></div>
<span style="font-size:11px;color:#c0d8f0;font-weight:300;letter-spacing:0.04em;">Normal</span>
<span style="margin-left:auto;font-size:9px;color:#5a7a96;letter-spacing:0.10em;font-weight:500;">CLASS 2</span>
</div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;
background:rgba(4,10,26,0.88);border-radius:4px;
border:1px solid rgba(255,170,68,0.25);">
<div style="width:6px;height:6px;border-radius:50%;background:#ffaa44;
box-shadow:0 0 6px #ffaa44aa;flex-shrink:0;"></div>
<span style="font-size:11px;color:#c0d8f0;font-weight:300;letter-spacing:0.04em;">Viral Pneumonia</span>
<span style="margin-left:auto;font-size:9px;color:#5a7a96;letter-spacing:0.10em;font-weight:500;">CLASS 3</span>
</div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;
background:rgba(4,10,26,0.88);border-radius:4px;
border:1px solid rgba(79,163,247,0.25);">
<div style="width:6px;height:6px;border-radius:50%;background:#4fa3f7;
box-shadow:0 0 6px #4fa3f7aa;flex-shrink:0;"></div>
<span style="font-size:11px;color:#c0d8f0;font-weight:300;letter-spacing:0.04em;">Lung Opacity</span>
<span style="margin-left:auto;font-size:9px;color:#5a7a96;letter-spacing:0.10em;font-weight:500;">CLASS 4</span>
</div>
</div>
<!-- Instructions -->
<div style="padding:12px 14px;background:rgba(4,10,26,0.85);
border:1px dashed rgba(60,130,210,0.35);border-radius:5px;
font-size:11px;color:#7aaccc;font-weight:300;
line-height:1.8;text-align:center;letter-spacing:0.03em;">
Upload a chest radiograph on the left, then press
<span style="color:#98c0d8;font-weight:500;">Run Analysis</span>
to generate a structured diagnostic report.
</div>
</div>
"""
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"""
<svg width="76" height="76" viewBox="0 0 76 76">
<circle cx="38" cy="38" r="28" fill="none" stroke="{ring_bg}" stroke-width="5"/>
<circle cx="38" cy="38" r="28" fill="none" stroke="{color}" stroke-width="5"
stroke-dasharray="{arc:.1f} 175.9" stroke-linecap="round"
transform="rotate(-90 38 38)"/>
<text x="38" y="43" text-anchor="middle"
font-family="'JetBrains Mono','Courier New',monospace"
font-size="13" font-weight="600" fill="{color}">{conf:.0f}%</text>
</svg>"""
# 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"""
<div style="display:grid;grid-template-columns:136px 1fr 52px;align-items:center;gap:12px;">
<span style="font-size:10.5px;font-weight:{'500' if is_primary else '300'};color:{'#98c0d8' if is_primary else '#5a7a96'};letter-spacing:0.04em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{cls}</span>
<div style="height:4px;background:rgba(18,38,68,0.90);border-radius:2px;overflow:hidden;">
<div style="width:{w}%;height:100%;border-radius:2px;background:{clr};{glow}transition:width 0.7s cubic-bezier(0.4,0,0.2,1);"></div>
</div>
<span style="font-family:'JetBrains Mono',monospace;font-size:9.5px;font-weight:500;color:{clr if is_primary else '#5a7a96'};text-align:right;white-space:nowrap;">{pct:.1f}<span style="font-size:7.5px;opacity:0.65;">%</span></span>
</div>"""
html = f"""
<div style="font-family:'Inter',sans-serif;">
<!-- Report header -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;
margin-bottom:16px;padding-bottom:12px;
border-bottom:1px solid rgba(60,120,200,0.28);">
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;gap:10px;align-items:baseline;">
<span style="font-size:9px;font-weight:600;letter-spacing:0.18em;color:#5a8aaa;
text-transform:uppercase;width:72px;flex-shrink:0;">Scan ID</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#8ec0e0;
letter-spacing:0.05em;">{scan_id}</span>
</div>
<div style="display:flex;gap:10px;align-items:baseline;">
<span style="font-size:9px;font-weight:600;letter-spacing:0.18em;color:#5a8aaa;
text-transform:uppercase;width:72px;flex-shrink:0;">Date/Time</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#8ec0e0;
letter-spacing:0.05em;">{timestamp}</span>
</div>
<div style="display:flex;gap:10px;align-items:baseline;">
<span style="font-size:9px;font-weight:600;letter-spacing:0.18em;color:#5a8aaa;
text-transform:uppercase;width:72px;flex-shrink:0;">Dimensions</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#8ec0e0;
letter-spacing:0.05em;">{dims} px</span>
</div>
<div style="display:flex;gap:10px;align-items:baseline;">
<span style="font-size:9px;font-weight:600;letter-spacing:0.18em;color:#5a8aaa;
text-transform:uppercase;width:72px;flex-shrink:0;">Modality</span>
<span style="font-size:10px;color:#8ec0e0;letter-spacing:0.04em;">CXR</span>
</div>
</div>
<div style="font-size:9px;font-weight:700;letter-spacing:0.20em;text-transform:uppercase;
padding:5px 11px;border-radius:3px;border:1px solid;
background:rgba({_badge_rgb(color)},0.15);
border-color:rgba({_badge_rgb(color)},0.40);
color:{color};flex-shrink:0;">
{tag}
</div>
</div>
<!-- Primary finding -->
<div style="font-size:9px;font-weight:600;letter-spacing:0.22em;color:#5a8aaa;
text-transform:uppercase;margin-bottom:10px;
padding-bottom:6px;border-bottom:1px solid rgba(60,120,200,0.18);">Primary Finding</div>
<div style="display:flex;align-items:center;gap:18px;padding:14px 16px;
background:rgba(4,10,26,0.80);border:1px solid rgba(60,130,220,0.22);
border-radius:6px;margin-bottom:16px;">
<div style="flex-shrink:0;">
{ring_svg}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:20px;font-weight:300;letter-spacing:0.06em;
line-height:1.15;margin-bottom:6px;color:{color};">{label}</div>
<div style="font-size:11px;font-weight:300;color:#6a90ac;letter-spacing:0.04em;">
Model confidence: <span style="font-family:'JetBrains Mono',monospace;font-size:10.5px;font-weight:500;color:{color};">{conf:.2f}%</span>
</div>
</div>
</div>
<!-- Clinical notes -->
<div style="font-size:9px;font-weight:600;letter-spacing:0.22em;color:#5a8aaa;
text-transform:uppercase;margin-bottom:10px;margin-top:16px;
padding-bottom:6px;border-bottom:1px solid rgba(60,120,200,0.18);">Clinical Impression</div>
<div style="font-size:11.5px;font-weight:300;color:#b4d0e4;
line-height:1.80;letter-spacing:0.025em;
padding:12px 14px;background:rgba(4,10,26,0.55);
border-radius:5px;border:1px solid rgba(60,130,220,0.14);">
{desc}
</div>
<!-- Differential -->
<div style="font-size:9px;font-weight:600;letter-spacing:0.22em;color:#5a8aaa;
text-transform:uppercase;margin-bottom:10px;margin-top:16px;
padding-bottom:6px;border-bottom:1px solid rgba(60,120,200,0.18);">Differential Diagnosis</div>
<div style="display:flex;flex-direction:column;gap:9px;">
{bars}
</div>
<!-- Disclaimer -->
<div style="margin-top:18px;padding:10px 14px;
background:rgba(35,22,6,0.45);border:1px solid rgba(130,90,25,0.28);
border-radius:4px;font-size:9.5px;font-weight:300;
color:#a08050;line-height:1.65;letter-spacing:0.02em;">
&#9888;&#65039; 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.
</div>
</div>"""
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("""
<div class="sys-header">
<div>
<div class="sys-title">Lung Disease Detection System</div>
<div class="sys-subtitle">
Chest Radiograph Analysis &nbsp;Β·&nbsp; CNN Classifier &nbsp;Β·&nbsp; v1.0
</div>
</div>
<div class="sys-indicators">
<div class="sys-dot ready">Model Ready</div>
<div class="sys-dot cpu">CPU</div>
</div>
</div>
""")
# ── 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('<div class="col-head">Input β€” Chest Radiograph</div>')
image_input = gr.Image(
type="pil",
label="Upload",
show_label=False,
elem_classes=["upload-zone"],
height=240,
)
gr.HTML("""
<div class="upload-meta">
<div class="meta-strip-row">
<div class="meta-strip-dot" style="background:#3dd68c;box-shadow:0 0 5px #3dd68c88;"></div>
<span class="meta-strip-key">Detects</span>
COVID-19, Normal, Pneumonia, Lung Opacity
</div>
<div class="meta-strip-row">
<div class="meta-strip-dot" style="background:#4fa3f7;box-shadow:0 0 5px #4fa3f788;"></div>
<span class="meta-strip-key">Modality</span>
PA or AP Chest Radiograph
</div>
<div class="meta-strip-row">
<div class="meta-strip-dot" style="background:#8898a8;"></div>
<span class="meta-strip-key">Model</span>
CNN &nbsp;Β·&nbsp; 128Γ—128 input &nbsp;Β·&nbsp; 4-class softmax
</div>
</div>
""")
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('<div class="col-head">Analysis Report</div>')
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,
)