Upload src/demo/streamlit_app.py with huggingface_hub
Browse files- src/demo/streamlit_app.py +1191 -1189
src/demo/streamlit_app.py
CHANGED
|
@@ -1,1189 +1,1191 @@
|
|
| 1 |
-
"""
|
| 2 |
-
NEXUS Streamlit Demo Application
|
| 3 |
-
|
| 4 |
-
Interactive demo for the NEXUS Maternal-Neonatal Care Platform.
|
| 5 |
-
Built with Google HAI-DEF models for the MedGemma Impact Challenge.
|
| 6 |
-
|
| 7 |
-
HAI-DEF Models Used:
|
| 8 |
-
- MedSigLIP: Medical image analysis (anemia, jaundice detection)
|
| 9 |
-
- HeAR: Health acoustic representations (cry analysis)
|
| 10 |
-
- MedGemma: Clinical reasoning and synthesis
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import streamlit as st
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
import sys
|
| 16 |
-
import os
|
| 17 |
-
import tempfile
|
| 18 |
-
import json
|
| 19 |
-
|
| 20 |
-
# Add parent directory to path for imports
|
| 21 |
-
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 22 |
-
|
| 23 |
-
# Page configuration
|
| 24 |
-
st.set_page_config(
|
| 25 |
-
page_title="NEXUS - Maternal-Neonatal Care",
|
| 26 |
-
page_icon="👶",
|
| 27 |
-
layout="wide",
|
| 28 |
-
initial_sidebar_state="expanded",
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
# Custom CSS
|
| 32 |
-
st.markdown("""
|
| 33 |
-
<style>
|
| 34 |
-
.main-header {
|
| 35 |
-
font-size: 2.5rem;
|
| 36 |
-
font-weight: bold;
|
| 37 |
-
color: #1f77b4;
|
| 38 |
-
text-align: center;
|
| 39 |
-
margin-bottom: 1rem;
|
| 40 |
-
}
|
| 41 |
-
.sub-header {
|
| 42 |
-
font-size: 1.2rem;
|
| 43 |
-
color: #666;
|
| 44 |
-
text-align: center;
|
| 45 |
-
margin-bottom: 2rem;
|
| 46 |
-
}
|
| 47 |
-
.risk-high {
|
| 48 |
-
background-color: #ffcccc;
|
| 49 |
-
border: 2px solid #ff0000;
|
| 50 |
-
padding: 1rem;
|
| 51 |
-
border-radius: 10px;
|
| 52 |
-
}
|
| 53 |
-
.risk-medium {
|
| 54 |
-
background-color: #fff3cd;
|
| 55 |
-
border: 2px solid #ffc107;
|
| 56 |
-
padding: 1rem;
|
| 57 |
-
border-radius: 10px;
|
| 58 |
-
}
|
| 59 |
-
.risk-low {
|
| 60 |
-
background-color: #d4edda;
|
| 61 |
-
border: 2px solid #28a745;
|
| 62 |
-
padding: 1rem;
|
| 63 |
-
border-radius: 10px;
|
| 64 |
-
}
|
| 65 |
-
.metric-card {
|
| 66 |
-
background-color: #f8f9fa;
|
| 67 |
-
padding: 1rem;
|
| 68 |
-
border-radius: 10px;
|
| 69 |
-
text-align: center;
|
| 70 |
-
}
|
| 71 |
-
.model-badge {
|
| 72 |
-
display: inline-block;
|
| 73 |
-
padding: 2px 10px;
|
| 74 |
-
border-radius: 12px;
|
| 75 |
-
font-size: 0.78rem;
|
| 76 |
-
font-weight: 600;
|
| 77 |
-
color: white;
|
| 78 |
-
letter-spacing: 0.3px;
|
| 79 |
-
}
|
| 80 |
-
.stMetric > div {
|
| 81 |
-
background-color: #f8f9fa;
|
| 82 |
-
padding: 0.5rem;
|
| 83 |
-
border-radius: 8px;
|
| 84 |
-
}
|
| 85 |
-
</style>
|
| 86 |
-
""", unsafe_allow_html=True)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
@st.cache_resource
|
| 90 |
-
def load_anemia_detector():
|
| 91 |
-
"""Load anemia detector model with error handling."""
|
| 92 |
-
try:
|
| 93 |
-
from nexus.anemia_detector import AnemiaDetector
|
| 94 |
-
detector = AnemiaDetector()
|
| 95 |
-
return detector, None
|
| 96 |
-
except Exception as e:
|
| 97 |
-
return None, str(e)
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
@st.cache_resource
|
| 101 |
-
def load_jaundice_detector():
|
| 102 |
-
"""Load jaundice detector model with error handling."""
|
| 103 |
-
try:
|
| 104 |
-
from nexus.jaundice_detector import JaundiceDetector
|
| 105 |
-
detector = JaundiceDetector()
|
| 106 |
-
return detector, None
|
| 107 |
-
except Exception as e:
|
| 108 |
-
return None, str(e)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
@st.cache_resource
|
| 112 |
-
def load_cry_analyzer():
|
| 113 |
-
"""Load cry analyzer with error handling."""
|
| 114 |
-
try:
|
| 115 |
-
from nexus.cry_analyzer import CryAnalyzer
|
| 116 |
-
analyzer = CryAnalyzer()
|
| 117 |
-
return analyzer, None
|
| 118 |
-
except Exception as e:
|
| 119 |
-
return None, str(e)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
@st.cache_resource
|
| 123 |
-
def load_clinical_synthesizer():
|
| 124 |
-
"""Load clinical synthesizer (MedGemma) with error handling."""
|
| 125 |
-
try:
|
| 126 |
-
import os
|
| 127 |
-
from nexus.clinical_synthesizer import ClinicalSynthesizer
|
| 128 |
-
use_medgemma = os.environ.get("NEXUS_USE_MEDGEMMA", "true").lower() != "false"
|
| 129 |
-
synthesizer = ClinicalSynthesizer(use_medgemma=use_medgemma)
|
| 130 |
-
return synthesizer, None
|
| 131 |
-
except Exception as e:
|
| 132 |
-
return None, str(e)
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def get_hai_def_info():
|
| 136 |
-
"""Get HAI-DEF models information with validated accuracy numbers."""
|
| 137 |
-
return {
|
| 138 |
-
"MedSigLIP": {
|
| 139 |
-
"name": "MedSigLIP (google/medsiglip-448)",
|
| 140 |
-
"use": "Image analysis for anemia and jaundice detection + bilirubin regression",
|
| 141 |
-
"method": "Zero-shot classification (max-similarity, 8 prompts/class) + trained SVM/LR classifiers on embeddings",
|
| 142 |
-
"accuracy": "Anemia: trained classifier on augmented data, Jaundice: trained classifier on 2,235 images, Bilirubin: MAE 2.67 mg/dL (r=0.77)",
|
| 143 |
-
"badge": "Vision",
|
| 144 |
-
"badge_color": "#388e3c",
|
| 145 |
-
},
|
| 146 |
-
"HeAR": {
|
| 147 |
-
"name": "HeAR (google/hear-pytorch)",
|
| 148 |
-
"use": "Infant cry analysis for asphyxia and cry type classification",
|
| 149 |
-
"method": "512-dim health acoustic embeddings + trained linear classifier on donate-a-cry dataset (5-class: hungry, belly_pain, burping, discomfort, tired)",
|
| 150 |
-
"accuracy": "Trained cry type classifier with asphyxia risk derivation from distress patterns",
|
| 151 |
-
"badge": "Audio",
|
| 152 |
-
"badge_color": "#f57c00",
|
| 153 |
-
},
|
| 154 |
-
"MedGemma": {
|
| 155 |
-
"name": "MedGemma 1.5 4B (google/medgemma-1.5-4b-it)",
|
| 156 |
-
"use": "Clinical reasoning and recommendation synthesis",
|
| 157 |
-
"method": "4-bit NF4 quantized inference with WHO IMNCI protocol-aligned synthesis and 6-agent reasoning traces",
|
| 158 |
-
"accuracy": "Protocol-aligned clinical recommendations with structured reasoning chains",
|
| 159 |
-
"badge": "Language",
|
| 160 |
-
"badge_color": "#1976d2",
|
| 161 |
-
},
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def main():
|
| 166 |
-
"""Main application."""
|
| 167 |
-
|
| 168 |
-
# Header
|
| 169 |
-
st.markdown('<div class="main-header">NEXUS</div>', unsafe_allow_html=True)
|
| 170 |
-
st.markdown(
|
| 171 |
-
'<div class="sub-header">AI-Powered Maternal-Neonatal Care Platform</div>',
|
| 172 |
-
unsafe_allow_html=True
|
| 173 |
-
)
|
| 174 |
-
|
| 175 |
-
# Sidebar
|
| 176 |
-
with st.sidebar:
|
| 177 |
-
st.markdown("## 🏥 NEXUS")
|
| 178 |
-
st.markdown("---")
|
| 179 |
-
|
| 180 |
-
assessment_type = st.radio(
|
| 181 |
-
"Select Assessment Type",
|
| 182 |
-
[
|
| 183 |
-
"Maternal Anemia Screening",
|
| 184 |
-
"Neonatal Jaundice Detection",
|
| 185 |
-
"Cry Analysis",
|
| 186 |
-
"Combined Assessment",
|
| 187 |
-
"Agentic Workflow",
|
| 188 |
-
"HAI-DEF Models Info"
|
| 189 |
-
],
|
| 190 |
-
index=0,
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
st.markdown("---")
|
| 194 |
-
st.markdown("### About NEXUS")
|
| 195 |
-
st.markdown("""
|
| 196 |
-
NEXUS uses AI to provide non-invasive screening for:
|
| 197 |
-
- **Maternal Anemia** via conjunctiva imaging
|
| 198 |
-
- **Neonatal Jaundice** via skin color analysis
|
| 199 |
-
- **Birth Asphyxia** via cry pattern analysis
|
| 200 |
-
|
| 201 |
-
Built with **Google HAI-DEF models** for the MedGemma Impact Challenge 2026.
|
| 202 |
-
""")
|
| 203 |
-
|
| 204 |
-
st.markdown("---")
|
| 205 |
-
st.markdown("### Edge AI Mode")
|
| 206 |
-
edge_mode = st.toggle("Enable Edge AI Mode", value=False, key="edge_mode")
|
| 207 |
-
if edge_mode:
|
| 208 |
-
st.success("Edge AI: INT8 quantized models + offline inference")
|
| 209 |
-
else:
|
| 210 |
-
st.info("Cloud mode: Full-precision HAI-DEF models")
|
| 211 |
-
|
| 212 |
-
st.markdown("---")
|
| 213 |
-
st.markdown("### HAI-DEF Models")
|
| 214 |
-
st.markdown("""
|
| 215 |
-
- **MedSigLIP**: Vision (trained classifiers)
|
| 216 |
-
- **HeAR**: Audio (trained cry classifier)
|
| 217 |
-
- **MedGemma 1.5**: Clinical AI (4-bit NF4)
|
| 218 |
-
""")
|
| 219 |
-
|
| 220 |
-
# Show Edge AI banner when enabled
|
| 221 |
-
if edge_mode:
|
| 222 |
-
render_edge_ai_banner()
|
| 223 |
-
|
| 224 |
-
# Main content based on selection
|
| 225 |
-
if assessment_type == "Maternal Anemia Screening":
|
| 226 |
-
render_anemia_screening()
|
| 227 |
-
elif assessment_type == "Neonatal Jaundice Detection":
|
| 228 |
-
render_jaundice_detection()
|
| 229 |
-
elif assessment_type == "Cry Analysis":
|
| 230 |
-
render_cry_analysis()
|
| 231 |
-
elif assessment_type == "Combined Assessment":
|
| 232 |
-
render_combined_assessment()
|
| 233 |
-
elif assessment_type == "Agentic Workflow":
|
| 234 |
-
render_agentic_workflow()
|
| 235 |
-
else:
|
| 236 |
-
render_hai_def_info()
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def render_edge_ai_banner():
|
| 240 |
-
"""Show Edge AI mode status and model metrics."""
|
| 241 |
-
st.markdown("""
|
| 242 |
-
<div style="background: linear-gradient(135deg, #1a237e 0%, #0d47a1 100%);
|
| 243 |
-
color: white; padding: 1rem 1.5rem; border-radius: 10px; margin-bottom: 1rem;">
|
| 244 |
-
<h4 style="margin:0; color: white;">Edge AI Mode Active</h4>
|
| 245 |
-
<p style="margin: 0.3rem 0 0 0; opacity: 0.9; font-size: 0.9rem;">
|
| 246 |
-
Running INT8 quantized models for offline-capable inference on low-resource devices.
|
| 247 |
-
</p>
|
| 248 |
-
</div>
|
| 249 |
-
""", unsafe_allow_html=True)
|
| 250 |
-
|
| 251 |
-
col1, col2, col3, col4 = st.columns(4)
|
| 252 |
-
with col1:
|
| 253 |
-
st.metric("MedSigLIP INT8", "111.2 MB", "-86% memory")
|
| 254 |
-
with col2:
|
| 255 |
-
st.metric("Acoustic Model", "0.6 MB", "INT8 quantized")
|
| 256 |
-
with col3:
|
| 257 |
-
st.metric("Text Embeddings", "12 KB", "Pre-computed")
|
| 258 |
-
with col4:
|
| 259 |
-
st.metric("Total Edge Size", "~289 MB", "Offline-ready")
|
| 260 |
-
|
| 261 |
-
with st.expander("Edge AI Details"):
|
| 262 |
-
st.markdown("""
|
| 263 |
-
**Quantization**: Dynamic INT8 (PyTorch `quantize_dynamic`, qnnpack backend)
|
| 264 |
-
|
| 265 |
-
| Component | Cloud (FP32) | Edge (INT8) | Compression |
|
| 266 |
-
|-----------|-------------|-------------|-------------|
|
| 267 |
-
| MedSigLIP Vision | 812.6 MB | 111.2 MB | **7.31x** |
|
| 268 |
-
| Acoustic Model | 0.665 MB | 0.599 MB | 1.11x |
|
| 269 |
-
| CPU Latency | 97.7 ms | ~65 ms (ARM est.) | ~1.5x faster |
|
| 270 |
-
|
| 271 |
-
**Target Devices**: Android 8.0+, ARM Cortex-A53, 2GB RAM
|
| 272 |
-
|
| 273 |
-
**Offline Capabilities**:
|
| 274 |
-
- Image analysis via INT8 MedSigLIP + pre-computed binary text embeddings
|
| 275 |
-
- Audio analysis via INT8 acoustic feature extractor
|
| 276 |
-
- Clinical reasoning via rule-based WHO IMNCI protocols (no MedGemma required)
|
| 277 |
-
""")
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def _cleanup_temp(path: str) -> None:
|
| 281 |
-
"""Safely remove a temporary file."""
|
| 282 |
-
try:
|
| 283 |
-
if path and os.path.exists(path):
|
| 284 |
-
os.unlink(path)
|
| 285 |
-
except OSError:
|
| 286 |
-
pass
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
def _save_upload_to_temp(uploaded_file, suffix: str) -> str:
|
| 290 |
-
"""Save an uploaded file to a temporary path and return the path."""
|
| 291 |
-
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 292 |
-
try:
|
| 293 |
-
tmp.write(uploaded_file.getvalue())
|
| 294 |
-
tmp.close()
|
| 295 |
-
return tmp.name
|
| 296 |
-
except Exception:
|
| 297 |
-
tmp.close()
|
| 298 |
-
_cleanup_temp(tmp.name)
|
| 299 |
-
raise
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def _model_badge(name: str, color: str) -> str:
|
| 303 |
-
"""Return an HTML badge for displaying which HAI-DEF model is active."""
|
| 304 |
-
return (
|
| 305 |
-
f'<span style="background:{color}; color:white; padding:2px 10px; '
|
| 306 |
-
f'border-radius:12px; font-size:0.78rem; font-weight:600; '
|
| 307 |
-
f'letter-spacing:0.3px;">{name}</span>'
|
| 308 |
-
)
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
def render_anemia_screening():
|
| 312 |
-
"""Render anemia screening interface."""
|
| 313 |
-
st.header("Maternal Anemia Screening")
|
| 314 |
-
st.markdown(
|
| 315 |
-
f"Upload a clear image of the inner eyelid (conjunctiva) for anemia screening. "
|
| 316 |
-
f'{_model_badge("MedSigLIP", "#388e3c")}',
|
| 317 |
-
unsafe_allow_html=True,
|
| 318 |
-
)
|
| 319 |
-
|
| 320 |
-
col1, col2 = st.columns([1, 1])
|
| 321 |
-
|
| 322 |
-
with col1:
|
| 323 |
-
st.subheader("Upload Image")
|
| 324 |
-
uploaded_file = st.file_uploader(
|
| 325 |
-
"Choose a conjunctiva image",
|
| 326 |
-
type=["jpg", "jpeg", "png"],
|
| 327 |
-
key="anemia_upload"
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
if uploaded_file:
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
st.
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
"
|
| 379 |
-
"
|
| 380 |
-
"
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
st.
|
| 395 |
-
|
| 396 |
-
f
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
st.
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
st.
|
| 457 |
-
with
|
| 458 |
-
st.metric("
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
st.
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
"
|
| 480 |
-
"
|
| 481 |
-
"
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
st.
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
with
|
| 550 |
-
st.metric("
|
| 551 |
-
with
|
| 552 |
-
st.metric("
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
f
|
| 577 |
-
f
|
| 578 |
-
f'{_model_badge("
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
"
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
st.
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
st.
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
st.
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
if st.session_state.findings["
|
| 691 |
-
findings["
|
| 692 |
-
if st.session_state.findings["
|
| 693 |
-
findings["
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
"
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
st.
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
"
|
| 743 |
-
"
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
st.
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
info
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
st.markdown(f"**
|
| 777 |
-
st.markdown(f"**
|
| 778 |
-
st.markdown(""
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
info
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
st.markdown(f"**
|
| 797 |
-
st.markdown(f"**
|
| 798 |
-
st.markdown(""
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
info
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
st.markdown(f"**
|
| 817 |
-
st.markdown(f"**
|
| 818 |
-
st.markdown(""
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
st.markdown(""
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
**
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
-
|
| 838 |
-
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
f
|
| 849 |
-
f
|
| 850 |
-
f'{_model_badge("
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
<div style="background: #
|
| 860 |
-
<span style="font-size: 1.5rem;">→</span>
|
| 861 |
-
<div style="background: #
|
| 862 |
-
<span style="font-size: 1.5rem;">→</span>
|
| 863 |
-
<div style="background: #
|
| 864 |
-
<span style="font-size: 1.5rem;">→</span>
|
| 865 |
-
<div style="background: #
|
| 866 |
-
<span style="font-size: 1.5rem;">→</span>
|
| 867 |
-
<div style="background: #
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
("
|
| 888 |
-
("
|
| 889 |
-
("
|
| 890 |
-
("
|
| 891 |
-
("
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
("
|
| 898 |
-
("
|
| 899 |
-
("
|
| 900 |
-
("
|
| 901 |
-
("
|
| 902 |
-
("
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
"
|
| 916 |
-
"
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
st.
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
"
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
with
|
| 1033 |
-
st.metric("
|
| 1034 |
-
with
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
"
|
| 1058 |
-
"
|
| 1059 |
-
"
|
| 1060 |
-
"
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
|
| 1153 |
-
|
|
| 1154 |
-
|
|
| 1155 |
-
|
|
| 1156 |
-
|
|
| 1157 |
-
|
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
with
|
| 1169 |
-
st.metric("
|
| 1170 |
-
with
|
| 1171 |
-
st.metric("
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NEXUS Streamlit Demo Application
|
| 3 |
+
|
| 4 |
+
Interactive demo for the NEXUS Maternal-Neonatal Care Platform.
|
| 5 |
+
Built with Google HAI-DEF models for the MedGemma Impact Challenge.
|
| 6 |
+
|
| 7 |
+
HAI-DEF Models Used:
|
| 8 |
+
- MedSigLIP: Medical image analysis (anemia, jaundice detection)
|
| 9 |
+
- HeAR: Health acoustic representations (cry analysis)
|
| 10 |
+
- MedGemma: Clinical reasoning and synthesis
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import streamlit as st
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import sys
|
| 16 |
+
import os
|
| 17 |
+
import tempfile
|
| 18 |
+
import json
|
| 19 |
+
|
| 20 |
+
# Add parent directory to path for imports
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 22 |
+
|
| 23 |
+
# Page configuration
|
| 24 |
+
st.set_page_config(
|
| 25 |
+
page_title="NEXUS - Maternal-Neonatal Care",
|
| 26 |
+
page_icon="👶",
|
| 27 |
+
layout="wide",
|
| 28 |
+
initial_sidebar_state="expanded",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Custom CSS
|
| 32 |
+
st.markdown("""
|
| 33 |
+
<style>
|
| 34 |
+
.main-header {
|
| 35 |
+
font-size: 2.5rem;
|
| 36 |
+
font-weight: bold;
|
| 37 |
+
color: #1f77b4;
|
| 38 |
+
text-align: center;
|
| 39 |
+
margin-bottom: 1rem;
|
| 40 |
+
}
|
| 41 |
+
.sub-header {
|
| 42 |
+
font-size: 1.2rem;
|
| 43 |
+
color: #666;
|
| 44 |
+
text-align: center;
|
| 45 |
+
margin-bottom: 2rem;
|
| 46 |
+
}
|
| 47 |
+
.risk-high {
|
| 48 |
+
background-color: #ffcccc;
|
| 49 |
+
border: 2px solid #ff0000;
|
| 50 |
+
padding: 1rem;
|
| 51 |
+
border-radius: 10px;
|
| 52 |
+
}
|
| 53 |
+
.risk-medium {
|
| 54 |
+
background-color: #fff3cd;
|
| 55 |
+
border: 2px solid #ffc107;
|
| 56 |
+
padding: 1rem;
|
| 57 |
+
border-radius: 10px;
|
| 58 |
+
}
|
| 59 |
+
.risk-low {
|
| 60 |
+
background-color: #d4edda;
|
| 61 |
+
border: 2px solid #28a745;
|
| 62 |
+
padding: 1rem;
|
| 63 |
+
border-radius: 10px;
|
| 64 |
+
}
|
| 65 |
+
.metric-card {
|
| 66 |
+
background-color: #f8f9fa;
|
| 67 |
+
padding: 1rem;
|
| 68 |
+
border-radius: 10px;
|
| 69 |
+
text-align: center;
|
| 70 |
+
}
|
| 71 |
+
.model-badge {
|
| 72 |
+
display: inline-block;
|
| 73 |
+
padding: 2px 10px;
|
| 74 |
+
border-radius: 12px;
|
| 75 |
+
font-size: 0.78rem;
|
| 76 |
+
font-weight: 600;
|
| 77 |
+
color: white;
|
| 78 |
+
letter-spacing: 0.3px;
|
| 79 |
+
}
|
| 80 |
+
.stMetric > div {
|
| 81 |
+
background-color: #f8f9fa;
|
| 82 |
+
padding: 0.5rem;
|
| 83 |
+
border-radius: 8px;
|
| 84 |
+
}
|
| 85 |
+
</style>
|
| 86 |
+
""", unsafe_allow_html=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@st.cache_resource
|
| 90 |
+
def load_anemia_detector():
|
| 91 |
+
"""Load anemia detector model with error handling."""
|
| 92 |
+
try:
|
| 93 |
+
from nexus.anemia_detector import AnemiaDetector
|
| 94 |
+
detector = AnemiaDetector()
|
| 95 |
+
return detector, None
|
| 96 |
+
except Exception as e:
|
| 97 |
+
return None, str(e)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@st.cache_resource
|
| 101 |
+
def load_jaundice_detector():
|
| 102 |
+
"""Load jaundice detector model with error handling."""
|
| 103 |
+
try:
|
| 104 |
+
from nexus.jaundice_detector import JaundiceDetector
|
| 105 |
+
detector = JaundiceDetector()
|
| 106 |
+
return detector, None
|
| 107 |
+
except Exception as e:
|
| 108 |
+
return None, str(e)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@st.cache_resource
|
| 112 |
+
def load_cry_analyzer():
|
| 113 |
+
"""Load cry analyzer with error handling."""
|
| 114 |
+
try:
|
| 115 |
+
from nexus.cry_analyzer import CryAnalyzer
|
| 116 |
+
analyzer = CryAnalyzer()
|
| 117 |
+
return analyzer, None
|
| 118 |
+
except Exception as e:
|
| 119 |
+
return None, str(e)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@st.cache_resource
|
| 123 |
+
def load_clinical_synthesizer():
|
| 124 |
+
"""Load clinical synthesizer (MedGemma) with error handling."""
|
| 125 |
+
try:
|
| 126 |
+
import os
|
| 127 |
+
from nexus.clinical_synthesizer import ClinicalSynthesizer
|
| 128 |
+
use_medgemma = os.environ.get("NEXUS_USE_MEDGEMMA", "true").lower() != "false"
|
| 129 |
+
synthesizer = ClinicalSynthesizer(use_medgemma=use_medgemma)
|
| 130 |
+
return synthesizer, None
|
| 131 |
+
except Exception as e:
|
| 132 |
+
return None, str(e)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def get_hai_def_info():
|
| 136 |
+
"""Get HAI-DEF models information with validated accuracy numbers."""
|
| 137 |
+
return {
|
| 138 |
+
"MedSigLIP": {
|
| 139 |
+
"name": "MedSigLIP (google/medsiglip-448)",
|
| 140 |
+
"use": "Image analysis for anemia and jaundice detection + bilirubin regression",
|
| 141 |
+
"method": "Zero-shot classification (max-similarity, 8 prompts/class) + trained SVM/LR classifiers on embeddings",
|
| 142 |
+
"accuracy": "Anemia: trained classifier on augmented data, Jaundice: trained classifier on 2,235 images, Bilirubin: MAE 2.67 mg/dL (r=0.77)",
|
| 143 |
+
"badge": "Vision",
|
| 144 |
+
"badge_color": "#388e3c",
|
| 145 |
+
},
|
| 146 |
+
"HeAR": {
|
| 147 |
+
"name": "HeAR (google/hear-pytorch)",
|
| 148 |
+
"use": "Infant cry analysis for asphyxia and cry type classification",
|
| 149 |
+
"method": "512-dim health acoustic embeddings + trained linear classifier on donate-a-cry dataset (5-class: hungry, belly_pain, burping, discomfort, tired)",
|
| 150 |
+
"accuracy": "Trained cry type classifier with asphyxia risk derivation from distress patterns",
|
| 151 |
+
"badge": "Audio",
|
| 152 |
+
"badge_color": "#f57c00",
|
| 153 |
+
},
|
| 154 |
+
"MedGemma": {
|
| 155 |
+
"name": "MedGemma 1.5 4B (google/medgemma-1.5-4b-it)",
|
| 156 |
+
"use": "Clinical reasoning and recommendation synthesis",
|
| 157 |
+
"method": "4-bit NF4 quantized inference with WHO IMNCI protocol-aligned synthesis and 6-agent reasoning traces",
|
| 158 |
+
"accuracy": "Protocol-aligned clinical recommendations with structured reasoning chains",
|
| 159 |
+
"badge": "Language",
|
| 160 |
+
"badge_color": "#1976d2",
|
| 161 |
+
},
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def main():
|
| 166 |
+
"""Main application."""
|
| 167 |
+
|
| 168 |
+
# Header
|
| 169 |
+
st.markdown('<div class="main-header">NEXUS</div>', unsafe_allow_html=True)
|
| 170 |
+
st.markdown(
|
| 171 |
+
'<div class="sub-header">AI-Powered Maternal-Neonatal Care Platform</div>',
|
| 172 |
+
unsafe_allow_html=True
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Sidebar
|
| 176 |
+
with st.sidebar:
|
| 177 |
+
st.markdown("## 🏥 NEXUS")
|
| 178 |
+
st.markdown("---")
|
| 179 |
+
|
| 180 |
+
assessment_type = st.radio(
|
| 181 |
+
"Select Assessment Type",
|
| 182 |
+
[
|
| 183 |
+
"Maternal Anemia Screening",
|
| 184 |
+
"Neonatal Jaundice Detection",
|
| 185 |
+
"Cry Analysis",
|
| 186 |
+
"Combined Assessment",
|
| 187 |
+
"Agentic Workflow",
|
| 188 |
+
"HAI-DEF Models Info"
|
| 189 |
+
],
|
| 190 |
+
index=0,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
st.markdown("---")
|
| 194 |
+
st.markdown("### About NEXUS")
|
| 195 |
+
st.markdown("""
|
| 196 |
+
NEXUS uses AI to provide non-invasive screening for:
|
| 197 |
+
- **Maternal Anemia** via conjunctiva imaging
|
| 198 |
+
- **Neonatal Jaundice** via skin color analysis
|
| 199 |
+
- **Birth Asphyxia** via cry pattern analysis
|
| 200 |
+
|
| 201 |
+
Built with **Google HAI-DEF models** for the MedGemma Impact Challenge 2026.
|
| 202 |
+
""")
|
| 203 |
+
|
| 204 |
+
st.markdown("---")
|
| 205 |
+
st.markdown("### Edge AI Mode")
|
| 206 |
+
edge_mode = st.toggle("Enable Edge AI Mode", value=False, key="edge_mode")
|
| 207 |
+
if edge_mode:
|
| 208 |
+
st.success("Edge AI: INT8 quantized models + offline inference")
|
| 209 |
+
else:
|
| 210 |
+
st.info("Cloud mode: Full-precision HAI-DEF models")
|
| 211 |
+
|
| 212 |
+
st.markdown("---")
|
| 213 |
+
st.markdown("### HAI-DEF Models")
|
| 214 |
+
st.markdown("""
|
| 215 |
+
- **MedSigLIP**: Vision (trained classifiers)
|
| 216 |
+
- **HeAR**: Audio (trained cry classifier)
|
| 217 |
+
- **MedGemma 1.5**: Clinical AI (4-bit NF4)
|
| 218 |
+
""")
|
| 219 |
+
|
| 220 |
+
# Show Edge AI banner when enabled
|
| 221 |
+
if edge_mode:
|
| 222 |
+
render_edge_ai_banner()
|
| 223 |
+
|
| 224 |
+
# Main content based on selection
|
| 225 |
+
if assessment_type == "Maternal Anemia Screening":
|
| 226 |
+
render_anemia_screening()
|
| 227 |
+
elif assessment_type == "Neonatal Jaundice Detection":
|
| 228 |
+
render_jaundice_detection()
|
| 229 |
+
elif assessment_type == "Cry Analysis":
|
| 230 |
+
render_cry_analysis()
|
| 231 |
+
elif assessment_type == "Combined Assessment":
|
| 232 |
+
render_combined_assessment()
|
| 233 |
+
elif assessment_type == "Agentic Workflow":
|
| 234 |
+
render_agentic_workflow()
|
| 235 |
+
else:
|
| 236 |
+
render_hai_def_info()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def render_edge_ai_banner():
|
| 240 |
+
"""Show Edge AI mode status and model metrics."""
|
| 241 |
+
st.markdown("""
|
| 242 |
+
<div style="background: linear-gradient(135deg, #1a237e 0%, #0d47a1 100%);
|
| 243 |
+
color: white; padding: 1rem 1.5rem; border-radius: 10px; margin-bottom: 1rem;">
|
| 244 |
+
<h4 style="margin:0; color: white;">Edge AI Mode Active</h4>
|
| 245 |
+
<p style="margin: 0.3rem 0 0 0; opacity: 0.9; font-size: 0.9rem;">
|
| 246 |
+
Running INT8 quantized models for offline-capable inference on low-resource devices.
|
| 247 |
+
</p>
|
| 248 |
+
</div>
|
| 249 |
+
""", unsafe_allow_html=True)
|
| 250 |
+
|
| 251 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 252 |
+
with col1:
|
| 253 |
+
st.metric("MedSigLIP INT8", "111.2 MB", "-86% memory")
|
| 254 |
+
with col2:
|
| 255 |
+
st.metric("Acoustic Model", "0.6 MB", "INT8 quantized")
|
| 256 |
+
with col3:
|
| 257 |
+
st.metric("Text Embeddings", "12 KB", "Pre-computed")
|
| 258 |
+
with col4:
|
| 259 |
+
st.metric("Total Edge Size", "~289 MB", "Offline-ready")
|
| 260 |
+
|
| 261 |
+
with st.expander("Edge AI Details"):
|
| 262 |
+
st.markdown("""
|
| 263 |
+
**Quantization**: Dynamic INT8 (PyTorch `quantize_dynamic`, qnnpack backend)
|
| 264 |
+
|
| 265 |
+
| Component | Cloud (FP32) | Edge (INT8) | Compression |
|
| 266 |
+
|-----------|-------------|-------------|-------------|
|
| 267 |
+
| MedSigLIP Vision | 812.6 MB | 111.2 MB | **7.31x** |
|
| 268 |
+
| Acoustic Model | 0.665 MB | 0.599 MB | 1.11x |
|
| 269 |
+
| CPU Latency | 97.7 ms | ~65 ms (ARM est.) | ~1.5x faster |
|
| 270 |
+
|
| 271 |
+
**Target Devices**: Android 8.0+, ARM Cortex-A53, 2GB RAM
|
| 272 |
+
|
| 273 |
+
**Offline Capabilities**:
|
| 274 |
+
- Image analysis via INT8 MedSigLIP + pre-computed binary text embeddings
|
| 275 |
+
- Audio analysis via INT8 acoustic feature extractor
|
| 276 |
+
- Clinical reasoning via rule-based WHO IMNCI protocols (no MedGemma required)
|
| 277 |
+
""")
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _cleanup_temp(path: str) -> None:
|
| 281 |
+
"""Safely remove a temporary file."""
|
| 282 |
+
try:
|
| 283 |
+
if path and os.path.exists(path):
|
| 284 |
+
os.unlink(path)
|
| 285 |
+
except OSError:
|
| 286 |
+
pass
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _save_upload_to_temp(uploaded_file, suffix: str) -> str:
|
| 290 |
+
"""Save an uploaded file to a temporary path and return the path."""
|
| 291 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 292 |
+
try:
|
| 293 |
+
tmp.write(uploaded_file.getvalue())
|
| 294 |
+
tmp.close()
|
| 295 |
+
return tmp.name
|
| 296 |
+
except Exception:
|
| 297 |
+
tmp.close()
|
| 298 |
+
_cleanup_temp(tmp.name)
|
| 299 |
+
raise
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _model_badge(name: str, color: str) -> str:
|
| 303 |
+
"""Return an HTML badge for displaying which HAI-DEF model is active."""
|
| 304 |
+
return (
|
| 305 |
+
f'<span style="background:{color}; color:white; padding:2px 10px; '
|
| 306 |
+
f'border-radius:12px; font-size:0.78rem; font-weight:600; '
|
| 307 |
+
f'letter-spacing:0.3px;">{name}</span>'
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def render_anemia_screening():
|
| 312 |
+
"""Render anemia screening interface."""
|
| 313 |
+
st.header("Maternal Anemia Screening")
|
| 314 |
+
st.markdown(
|
| 315 |
+
f"Upload a clear image of the inner eyelid (conjunctiva) for anemia screening. "
|
| 316 |
+
f'{_model_badge("MedSigLIP", "#388e3c")}',
|
| 317 |
+
unsafe_allow_html=True,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
col1, col2 = st.columns([1, 1])
|
| 321 |
+
|
| 322 |
+
with col1:
|
| 323 |
+
st.subheader("Upload Image")
|
| 324 |
+
uploaded_file = st.file_uploader(
|
| 325 |
+
"Choose a conjunctiva image",
|
| 326 |
+
type=["jpg", "jpeg", "png"],
|
| 327 |
+
key="anemia_upload"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
if uploaded_file:
|
| 331 |
+
image_bytes = uploaded_file.getvalue()
|
| 332 |
+
st.image(image_bytes, caption="Uploaded Image", use_container_width=True)
|
| 333 |
+
|
| 334 |
+
with col2:
|
| 335 |
+
st.subheader("Analysis Results")
|
| 336 |
+
|
| 337 |
+
if uploaded_file:
|
| 338 |
+
with st.spinner("Analyzing image..."):
|
| 339 |
+
tmp_path = None
|
| 340 |
+
try:
|
| 341 |
+
detector, load_err = load_anemia_detector()
|
| 342 |
+
if detector is None:
|
| 343 |
+
st.error(f"Could not load model: {load_err}")
|
| 344 |
+
return
|
| 345 |
+
|
| 346 |
+
tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
|
| 347 |
+
|
| 348 |
+
result = detector.detect(tmp_path)
|
| 349 |
+
color_info = detector.analyze_color_features(tmp_path)
|
| 350 |
+
|
| 351 |
+
# Display results
|
| 352 |
+
risk_class = f"risk-{result['risk_level']}"
|
| 353 |
+
st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
|
| 354 |
+
|
| 355 |
+
if result["is_anemic"]:
|
| 356 |
+
st.error("⚠️ ANEMIA DETECTED")
|
| 357 |
+
else:
|
| 358 |
+
st.success("✅ No Anemia Detected")
|
| 359 |
+
|
| 360 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 361 |
+
|
| 362 |
+
# Metrics
|
| 363 |
+
col_a, col_b, col_c = st.columns(3)
|
| 364 |
+
with col_a:
|
| 365 |
+
st.metric("Confidence", f"{result['confidence']:.1%}")
|
| 366 |
+
with col_b:
|
| 367 |
+
st.metric("Risk Level", result['risk_level'].upper())
|
| 368 |
+
with col_c:
|
| 369 |
+
st.metric("Est. Hemoglobin", f"{color_info['estimated_hemoglobin']} g/dL")
|
| 370 |
+
|
| 371 |
+
# Recommendation
|
| 372 |
+
st.markdown("### Recommendation")
|
| 373 |
+
st.info(result["recommendation"])
|
| 374 |
+
|
| 375 |
+
# Color analysis
|
| 376 |
+
with st.expander("Technical Details"):
|
| 377 |
+
st.json({
|
| 378 |
+
"anemia_score": round(result["anemia_score"], 3),
|
| 379 |
+
"healthy_score": round(result["healthy_score"], 3),
|
| 380 |
+
"red_ratio": round(color_info["red_ratio"], 3),
|
| 381 |
+
"pallor_index": round(color_info["pallor_index"], 3),
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
except Exception as e:
|
| 385 |
+
st.error(f"Error analyzing image: {e}")
|
| 386 |
+
finally:
|
| 387 |
+
_cleanup_temp(tmp_path)
|
| 388 |
+
else:
|
| 389 |
+
st.info("👆 Upload an image to begin analysis")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def render_jaundice_detection():
|
| 393 |
+
"""Render jaundice detection interface."""
|
| 394 |
+
st.header("Neonatal Jaundice Detection")
|
| 395 |
+
st.markdown(
|
| 396 |
+
f"Upload an image of the newborn's skin or sclera for jaundice assessment. "
|
| 397 |
+
f'{_model_badge("MedSigLIP", "#388e3c")}',
|
| 398 |
+
unsafe_allow_html=True,
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
col1, col2 = st.columns([1, 1])
|
| 402 |
+
|
| 403 |
+
with col1:
|
| 404 |
+
st.subheader("Upload Image")
|
| 405 |
+
uploaded_file = st.file_uploader(
|
| 406 |
+
"Choose a neonatal image",
|
| 407 |
+
type=["jpg", "jpeg", "png"],
|
| 408 |
+
key="jaundice_upload"
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
if uploaded_file:
|
| 412 |
+
image_bytes = uploaded_file.getvalue()
|
| 413 |
+
st.image(image_bytes, caption="Uploaded Image", use_container_width=True)
|
| 414 |
+
|
| 415 |
+
# Patient info
|
| 416 |
+
st.subheader("Patient Information (Optional)")
|
| 417 |
+
age_days = st.number_input("Age (days)", min_value=0, max_value=28, value=3)
|
| 418 |
+
birth_weight = st.number_input("Birth weight (grams)", min_value=500, max_value=5000, value=3000)
|
| 419 |
+
|
| 420 |
+
with col2:
|
| 421 |
+
st.subheader("Analysis Results")
|
| 422 |
+
|
| 423 |
+
if uploaded_file:
|
| 424 |
+
with st.spinner("Analyzing image..."):
|
| 425 |
+
tmp_path = None
|
| 426 |
+
try:
|
| 427 |
+
detector, load_err = load_jaundice_detector()
|
| 428 |
+
if detector is None:
|
| 429 |
+
st.error(f"Could not load model: {load_err}")
|
| 430 |
+
return
|
| 431 |
+
|
| 432 |
+
tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
|
| 433 |
+
|
| 434 |
+
result = detector.detect(tmp_path)
|
| 435 |
+
zone_info = detector.analyze_kramer_zones(tmp_path)
|
| 436 |
+
|
| 437 |
+
# Display results
|
| 438 |
+
risk_class = "risk-high" if result["needs_phototherapy"] else (
|
| 439 |
+
"risk-medium" if result["severity"] in ["moderate", "mild"] else "risk-low"
|
| 440 |
+
)
|
| 441 |
+
st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
|
| 442 |
+
|
| 443 |
+
if result["has_jaundice"]:
|
| 444 |
+
st.warning(f"⚠️ JAUNDICE DETECTED - {result['severity'].upper()}")
|
| 445 |
+
else:
|
| 446 |
+
st.success("✅ No Significant Jaundice")
|
| 447 |
+
|
| 448 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 449 |
+
|
| 450 |
+
# Metrics - show ML bilirubin if available
|
| 451 |
+
col_a, col_b, col_c = st.columns(3)
|
| 452 |
+
with col_a:
|
| 453 |
+
bili_value = result.get('estimated_bilirubin_ml', result.get('estimated_bilirubin', 0))
|
| 454 |
+
bili_method = result.get('bilirubin_method', 'Color Analysis')
|
| 455 |
+
st.metric("Est. Bilirubin", f"{bili_value} mg/dL")
|
| 456 |
+
st.caption(f"Method: {bili_method}")
|
| 457 |
+
with col_b:
|
| 458 |
+
st.metric("Severity", result['severity'].upper())
|
| 459 |
+
with col_c:
|
| 460 |
+
st.metric("Kramer Zone", zone_info['kramer_zone'])
|
| 461 |
+
|
| 462 |
+
# Phototherapy indicator
|
| 463 |
+
if result["needs_phototherapy"]:
|
| 464 |
+
st.error("🔆 PHOTOTHERAPY RECOMMENDED")
|
| 465 |
+
|
| 466 |
+
# Recommendation
|
| 467 |
+
st.markdown("### Recommendation")
|
| 468 |
+
st.info(result["recommendation"])
|
| 469 |
+
|
| 470 |
+
# Zone analysis
|
| 471 |
+
with st.expander("Kramer Zone Analysis"):
|
| 472 |
+
st.write(f"**Zone**: {zone_info['kramer_zone']} - {zone_info['zone_description']}")
|
| 473 |
+
st.write(f"**Yellow Index**: {zone_info['yellow_index']}")
|
| 474 |
+
st.progress(min(zone_info['yellow_index'] * 2, 1.0))
|
| 475 |
+
|
| 476 |
+
# Technical details
|
| 477 |
+
with st.expander("Technical Details"):
|
| 478 |
+
details = {
|
| 479 |
+
"jaundice_score": round(result["jaundice_score"], 3),
|
| 480 |
+
"confidence": round(result["confidence"], 3),
|
| 481 |
+
"model": result.get("model", "unknown"),
|
| 482 |
+
"model_type": result.get("model_type", "unknown"),
|
| 483 |
+
"bilirubin_method": result.get("bilirubin_method", "Color Analysis"),
|
| 484 |
+
}
|
| 485 |
+
if result.get("estimated_bilirubin_ml") is not None:
|
| 486 |
+
details["bilirubin_ml"] = result["estimated_bilirubin_ml"]
|
| 487 |
+
details["bilirubin_color"] = result["estimated_bilirubin"]
|
| 488 |
+
st.json(details)
|
| 489 |
+
|
| 490 |
+
except Exception as e:
|
| 491 |
+
st.error(f"Error analyzing image: {e}")
|
| 492 |
+
finally:
|
| 493 |
+
_cleanup_temp(tmp_path)
|
| 494 |
+
else:
|
| 495 |
+
st.info("👆 Upload an image to begin analysis")
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def render_cry_analysis():
|
| 499 |
+
"""Render cry analysis interface."""
|
| 500 |
+
st.header("Infant Cry Analysis")
|
| 501 |
+
st.markdown(
|
| 502 |
+
f"Upload an audio recording of the infant's cry for analysis. "
|
| 503 |
+
f'{_model_badge("HeAR", "#f57c00")}',
|
| 504 |
+
unsafe_allow_html=True,
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
col1, col2 = st.columns([1, 1])
|
| 508 |
+
|
| 509 |
+
with col1:
|
| 510 |
+
st.subheader("Upload Audio")
|
| 511 |
+
uploaded_file = st.file_uploader(
|
| 512 |
+
"Choose a cry audio file",
|
| 513 |
+
type=["wav", "mp3", "ogg"],
|
| 514 |
+
key="cry_upload"
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
if uploaded_file:
|
| 518 |
+
st.audio(uploaded_file)
|
| 519 |
+
|
| 520 |
+
with col2:
|
| 521 |
+
st.subheader("Analysis Results")
|
| 522 |
+
|
| 523 |
+
if uploaded_file:
|
| 524 |
+
with st.spinner("Analyzing cry..."):
|
| 525 |
+
tmp_path = None
|
| 526 |
+
try:
|
| 527 |
+
analyzer, load_err = load_cry_analyzer()
|
| 528 |
+
if analyzer is None:
|
| 529 |
+
st.error(f"Could not load model: {load_err}")
|
| 530 |
+
return
|
| 531 |
+
|
| 532 |
+
tmp_path = _save_upload_to_temp(uploaded_file, ".wav")
|
| 533 |
+
|
| 534 |
+
result = analyzer.analyze(tmp_path)
|
| 535 |
+
|
| 536 |
+
# Display results
|
| 537 |
+
risk_class = f"risk-{result['risk_level']}"
|
| 538 |
+
st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
|
| 539 |
+
|
| 540 |
+
if result["is_abnormal"]:
|
| 541 |
+
st.error("⚠️ ABNORMAL CRY PATTERN DETECTED")
|
| 542 |
+
else:
|
| 543 |
+
st.success("✅ Normal Cry Pattern")
|
| 544 |
+
|
| 545 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 546 |
+
|
| 547 |
+
# Metrics
|
| 548 |
+
col_a, col_b, col_c = st.columns(3)
|
| 549 |
+
with col_a:
|
| 550 |
+
st.metric("Asphyxia Risk", f"{result['asphyxia_risk']:.1%}")
|
| 551 |
+
with col_b:
|
| 552 |
+
st.metric("Cry Type", result['cry_type'].title())
|
| 553 |
+
with col_c:
|
| 554 |
+
st.metric("F0 (Pitch)", f"{result['features']['f0_mean']:.0f} Hz")
|
| 555 |
+
|
| 556 |
+
# Recommendation
|
| 557 |
+
st.markdown("### Recommendation")
|
| 558 |
+
st.info(result["recommendation"])
|
| 559 |
+
|
| 560 |
+
# Acoustic features
|
| 561 |
+
with st.expander("Acoustic Features"):
|
| 562 |
+
st.json(result["features"])
|
| 563 |
+
|
| 564 |
+
except Exception as e:
|
| 565 |
+
st.error(f"Error analyzing audio: {e}")
|
| 566 |
+
finally:
|
| 567 |
+
_cleanup_temp(tmp_path)
|
| 568 |
+
else:
|
| 569 |
+
st.info("👆 Upload an audio file to begin analysis")
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def render_combined_assessment():
|
| 573 |
+
"""Render combined assessment interface using Clinical Synthesizer."""
|
| 574 |
+
st.header("Combined Clinical Assessment")
|
| 575 |
+
st.markdown(
|
| 576 |
+
f"Upload multiple inputs for a comprehensive assessment using **MedGemma Clinical Synthesizer**. "
|
| 577 |
+
f"This combines findings from all HAI-DEF models to provide integrated clinical recommendations. "
|
| 578 |
+
f'{_model_badge("MedSigLIP", "#388e3c")} '
|
| 579 |
+
f'{_model_badge("HeAR", "#f57c00")} '
|
| 580 |
+
f'{_model_badge("MedGemma", "#1976d2")}',
|
| 581 |
+
unsafe_allow_html=True,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Reset findings each time this tab is rendered to prevent
|
| 585 |
+
# stale data from previous patients contaminating results
|
| 586 |
+
st.session_state.findings = {
|
| 587 |
+
"anemia": None,
|
| 588 |
+
"jaundice": None,
|
| 589 |
+
"cry": None
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
col1, col2, col3 = st.columns(3)
|
| 593 |
+
|
| 594 |
+
with col1:
|
| 595 |
+
st.subheader("🩸 Anemia Screening")
|
| 596 |
+
anemia_file = st.file_uploader(
|
| 597 |
+
"Conjunctiva image",
|
| 598 |
+
type=["jpg", "jpeg", "png"],
|
| 599 |
+
key="combined_anemia"
|
| 600 |
+
)
|
| 601 |
+
if anemia_file:
|
| 602 |
+
st.image(anemia_file.getvalue(), use_container_width=True)
|
| 603 |
+
with st.spinner("Analyzing..."):
|
| 604 |
+
try:
|
| 605 |
+
detector, load_err = load_anemia_detector()
|
| 606 |
+
if detector is None:
|
| 607 |
+
st.error(f"Model error: {load_err}")
|
| 608 |
+
else:
|
| 609 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
|
| 610 |
+
tmp.write(anemia_file.getvalue())
|
| 611 |
+
result = detector.detect(tmp.name)
|
| 612 |
+
st.session_state.findings["anemia"] = result
|
| 613 |
+
if result["is_anemic"]:
|
| 614 |
+
st.error(f"Anemia: {result['risk_level'].upper()}")
|
| 615 |
+
else:
|
| 616 |
+
st.success("No Anemia")
|
| 617 |
+
except Exception as e:
|
| 618 |
+
st.error(f"Error: {e}")
|
| 619 |
+
|
| 620 |
+
with col2:
|
| 621 |
+
st.subheader("👶 Jaundice Detection")
|
| 622 |
+
jaundice_file = st.file_uploader(
|
| 623 |
+
"Neonatal skin image",
|
| 624 |
+
type=["jpg", "jpeg", "png"],
|
| 625 |
+
key="combined_jaundice"
|
| 626 |
+
)
|
| 627 |
+
if jaundice_file:
|
| 628 |
+
st.image(jaundice_file.getvalue(), use_container_width=True)
|
| 629 |
+
with st.spinner("Analyzing..."):
|
| 630 |
+
try:
|
| 631 |
+
detector, load_err = load_jaundice_detector()
|
| 632 |
+
if detector is None:
|
| 633 |
+
st.error(f"Model error: {load_err}")
|
| 634 |
+
else:
|
| 635 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
|
| 636 |
+
tmp.write(jaundice_file.getvalue())
|
| 637 |
+
result = detector.detect(tmp.name)
|
| 638 |
+
st.session_state.findings["jaundice"] = result
|
| 639 |
+
if result["has_jaundice"]:
|
| 640 |
+
st.warning(f"Jaundice: {result['severity'].upper()}")
|
| 641 |
+
else:
|
| 642 |
+
st.success("No Jaundice")
|
| 643 |
+
except Exception as e:
|
| 644 |
+
st.error(f"Error: {e}")
|
| 645 |
+
|
| 646 |
+
with col3:
|
| 647 |
+
st.subheader("🔊 Cry Analysis")
|
| 648 |
+
cry_file = st.file_uploader(
|
| 649 |
+
"Cry audio",
|
| 650 |
+
type=["wav", "mp3", "ogg"],
|
| 651 |
+
key="combined_cry"
|
| 652 |
+
)
|
| 653 |
+
if cry_file:
|
| 654 |
+
st.audio(cry_file)
|
| 655 |
+
with st.spinner("Analyzing..."):
|
| 656 |
+
try:
|
| 657 |
+
analyzer, load_err = load_cry_analyzer()
|
| 658 |
+
if analyzer is None:
|
| 659 |
+
st.error(f"Model error: {load_err}")
|
| 660 |
+
raise RuntimeError(load_err)
|
| 661 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
| 662 |
+
tmp.write(cry_file.getvalue())
|
| 663 |
+
result = analyzer.analyze(tmp.name)
|
| 664 |
+
st.session_state.findings["cry"] = result
|
| 665 |
+
if result["is_abnormal"]:
|
| 666 |
+
st.error(f"Abnormal Cry: {result['risk_level'].upper()}")
|
| 667 |
+
else:
|
| 668 |
+
st.success("Normal Cry")
|
| 669 |
+
except Exception as e:
|
| 670 |
+
st.error(f"Error: {e}")
|
| 671 |
+
|
| 672 |
+
# Clinical Synthesis Section
|
| 673 |
+
st.markdown("---")
|
| 674 |
+
st.subheader("🏥 Clinical Synthesis (MedGemma)")
|
| 675 |
+
|
| 676 |
+
# Check if any findings are available
|
| 677 |
+
has_findings = any(v is not None for v in st.session_state.findings.values())
|
| 678 |
+
|
| 679 |
+
if has_findings:
|
| 680 |
+
if st.button("Generate Clinical Synthesis", type="primary"):
|
| 681 |
+
with st.spinner("Synthesizing findings with MedGemma..."):
|
| 682 |
+
try:
|
| 683 |
+
synthesizer, load_err = load_clinical_synthesizer()
|
| 684 |
+
if synthesizer is None:
|
| 685 |
+
st.error(f"Could not load synthesizer: {load_err}")
|
| 686 |
+
return
|
| 687 |
+
|
| 688 |
+
# Prepare findings dict
|
| 689 |
+
findings = {}
|
| 690 |
+
if st.session_state.findings["anemia"]:
|
| 691 |
+
findings["anemia"] = st.session_state.findings["anemia"]
|
| 692 |
+
if st.session_state.findings["jaundice"]:
|
| 693 |
+
findings["jaundice"] = st.session_state.findings["jaundice"]
|
| 694 |
+
if st.session_state.findings["cry"]:
|
| 695 |
+
findings["cry"] = st.session_state.findings["cry"]
|
| 696 |
+
|
| 697 |
+
synthesis = synthesizer.synthesize(findings)
|
| 698 |
+
|
| 699 |
+
# Display synthesis results
|
| 700 |
+
severity_level = synthesis.get("severity_level", "GREEN")
|
| 701 |
+
severity_colors = {
|
| 702 |
+
"GREEN": ("🟢", "#d4edda", "#155724"),
|
| 703 |
+
"YELLOW": ("🟡", "#fff3cd", "#856404"),
|
| 704 |
+
"RED": ("🔴", "#f8d7da", "#721c24")
|
| 705 |
+
}
|
| 706 |
+
emoji, bg_color, text_color = severity_colors.get(severity_level, ("⚪", "#f8f9fa", "#000"))
|
| 707 |
+
|
| 708 |
+
st.markdown(f"""
|
| 709 |
+
<div style="background-color: {bg_color}; padding: 1.5rem; border-radius: 10px; margin: 1rem 0;">
|
| 710 |
+
<h3 style="color: {text_color}; margin: 0;">{emoji} Severity: {severity_level}</h3>
|
| 711 |
+
<p style="color: {text_color}; font-size: 1.1rem; margin-top: 0.5rem;">{synthesis.get('severity_description', '')}</p>
|
| 712 |
+
</div>
|
| 713 |
+
""", unsafe_allow_html=True)
|
| 714 |
+
|
| 715 |
+
# Summary
|
| 716 |
+
st.markdown("### Summary")
|
| 717 |
+
st.info(synthesis.get("summary", "No summary available"))
|
| 718 |
+
|
| 719 |
+
# Actions
|
| 720 |
+
if synthesis.get("immediate_actions"):
|
| 721 |
+
st.markdown("### Immediate Actions")
|
| 722 |
+
for action in synthesis["immediate_actions"]:
|
| 723 |
+
st.markdown(f"- {action}")
|
| 724 |
+
|
| 725 |
+
# Referral
|
| 726 |
+
col_a, col_b = st.columns(2)
|
| 727 |
+
with col_a:
|
| 728 |
+
st.markdown("### Referral Status")
|
| 729 |
+
if synthesis.get("referral_needed"):
|
| 730 |
+
st.error(f"⚠️ REFERRAL NEEDED: {synthesis.get('referral_urgency', 'standard').upper()}")
|
| 731 |
+
else:
|
| 732 |
+
st.success("✅ No referral needed")
|
| 733 |
+
|
| 734 |
+
with col_b:
|
| 735 |
+
st.markdown("### Follow-up")
|
| 736 |
+
st.info(synthesis.get("follow_up", "Schedule routine follow-up"))
|
| 737 |
+
|
| 738 |
+
# Technical details
|
| 739 |
+
with st.expander("Technical Details"):
|
| 740 |
+
model_name = synthesis.get("model", "unknown")
|
| 741 |
+
st.json({
|
| 742 |
+
"model": model_name,
|
| 743 |
+
"model_id": synthesis.get("model_id", ""),
|
| 744 |
+
"generated_at": synthesis.get("generated_at"),
|
| 745 |
+
"urgent_conditions": synthesis.get("urgent_conditions", []),
|
| 746 |
+
})
|
| 747 |
+
if model_name and "Fallback" not in str(model_name):
|
| 748 |
+
st.success(f"Synthesis powered by {model_name}")
|
| 749 |
+
elif "Fallback" in str(model_name):
|
| 750 |
+
st.warning("Using rule-based fallback (MedGemma unavailable)")
|
| 751 |
+
|
| 752 |
+
except Exception as e:
|
| 753 |
+
st.error(f"Error generating synthesis: {e}")
|
| 754 |
+
else:
|
| 755 |
+
st.info("👆 Upload at least one input (image or audio) to generate clinical synthesis")
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def render_hai_def_info():
|
| 759 |
+
"""Render HAI-DEF models information."""
|
| 760 |
+
st.header("Google HAI-DEF Models")
|
| 761 |
+
st.markdown("""
|
| 762 |
+
NEXUS is built using **Google Health AI Developer Foundations (HAI-DEF)** models,
|
| 763 |
+
designed specifically for healthcare applications in resource-limited settings.
|
| 764 |
+
""")
|
| 765 |
+
|
| 766 |
+
hai_def = get_hai_def_info()
|
| 767 |
+
|
| 768 |
+
# MedSigLIP
|
| 769 |
+
st.markdown("---")
|
| 770 |
+
col1, col2 = st.columns([1, 2])
|
| 771 |
+
with col1:
|
| 772 |
+
st.markdown("### 🖼️ MedSigLIP")
|
| 773 |
+
st.info("google/medsiglip-448\n\nHAI-DEF Vision Model")
|
| 774 |
+
with col2:
|
| 775 |
+
info = hai_def["MedSigLIP"]
|
| 776 |
+
st.markdown(f"**Model**: {info['name']}")
|
| 777 |
+
st.markdown(f"**Use Case**: {info['use']}")
|
| 778 |
+
st.markdown(f"**Method**: {info['method']}")
|
| 779 |
+
st.markdown(f"**Validated Performance**: {info['accuracy']}")
|
| 780 |
+
st.markdown("""
|
| 781 |
+
MedSigLIP enables zero-shot medical image classification using
|
| 782 |
+
text prompts. NEXUS extends this with trained SVM/LR classifiers
|
| 783 |
+
on MedSigLIP embeddings (with data augmentation) for improved
|
| 784 |
+
accuracy, plus a novel 3-layer MLP regression head for continuous
|
| 785 |
+
bilirubin prediction from frozen embeddings.
|
| 786 |
+
""")
|
| 787 |
+
|
| 788 |
+
# HeAR
|
| 789 |
+
st.markdown("---")
|
| 790 |
+
col1, col2 = st.columns([1, 2])
|
| 791 |
+
with col1:
|
| 792 |
+
st.markdown("### 🔊 HeAR")
|
| 793 |
+
st.info("google/hear-pytorch\n\nHAI-DEF Audio Model")
|
| 794 |
+
with col2:
|
| 795 |
+
info = hai_def["HeAR"]
|
| 796 |
+
st.markdown(f"**Model**: {info['name']}")
|
| 797 |
+
st.markdown(f"**Use Case**: {info['use']}")
|
| 798 |
+
st.markdown(f"**Method**: {info['method']}")
|
| 799 |
+
st.markdown(f"**Validated Performance**: {info['accuracy']}")
|
| 800 |
+
st.markdown("""
|
| 801 |
+
HeAR (Health Acoustic Representations) produces 512-dim embeddings
|
| 802 |
+
from 2-second audio clips at 16kHz. NEXUS trains a linear classifier
|
| 803 |
+
on HeAR embeddings for 5-class cry type classification (hungry,
|
| 804 |
+
belly_pain, burping, discomfort, tired) and derives asphyxia risk
|
| 805 |
+
from distress patterns.
|
| 806 |
+
""")
|
| 807 |
+
|
| 808 |
+
# MedGemma
|
| 809 |
+
st.markdown("---")
|
| 810 |
+
col1, col2 = st.columns([1, 2])
|
| 811 |
+
with col1:
|
| 812 |
+
st.markdown("### 🧠 MedGemma")
|
| 813 |
+
st.info("google/medgemma-1.5-4b-it\n\nHAI-DEF Language Model")
|
| 814 |
+
with col2:
|
| 815 |
+
info = hai_def["MedGemma"]
|
| 816 |
+
st.markdown(f"**Model**: {info['name']}")
|
| 817 |
+
st.markdown(f"**Use Case**: {info['use']}")
|
| 818 |
+
st.markdown(f"**Method**: {info['method']}")
|
| 819 |
+
st.markdown(f"**Validated Performance**: {info['accuracy']}")
|
| 820 |
+
st.markdown("""
|
| 821 |
+
MedGemma 1.5 provides clinical reasoning capabilities via 4-bit NF4
|
| 822 |
+
quantized inference (~2 GB VRAM). It synthesizes multi-modal findings
|
| 823 |
+
into actionable recommendations following WHO IMNCI protocols,
|
| 824 |
+
producing structured reasoning chains within the 6-agent pipeline.
|
| 825 |
+
""")
|
| 826 |
+
|
| 827 |
+
# Competition Info
|
| 828 |
+
st.markdown("---")
|
| 829 |
+
st.subheader("🏆 MedGemma Impact Challenge 2026")
|
| 830 |
+
st.markdown("""
|
| 831 |
+
NEXUS is being developed for the [MedGemma Impact Challenge](https://www.kaggle.com/competitions/medgemma-impact-challenge-2026)
|
| 832 |
+
on Kaggle.
|
| 833 |
+
|
| 834 |
+
**Competition Focus**: Solutions for resource-limited healthcare settings using HAI-DEF models.
|
| 835 |
+
|
| 836 |
+
**NEXUS Impact**:
|
| 837 |
+
- 📍 Target: Sub-Saharan Africa and South Asia
|
| 838 |
+
- 👩⚕️ Users: Community Health Workers
|
| 839 |
+
- 🎯 Goals: Reduce maternal/neonatal mortality
|
| 840 |
+
- 📱 Deployment: Offline-capable mobile app
|
| 841 |
+
""")
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
def render_agentic_workflow():
|
| 845 |
+
"""Render the agentic workflow interface with reasoning traces."""
|
| 846 |
+
st.header("Agentic Clinical Workflow")
|
| 847 |
+
st.markdown(
|
| 848 |
+
f"**6-Agent Pipeline** with step-by-step reasoning traces. "
|
| 849 |
+
f"Each agent explains its clinical decision process, providing a full audit trail. "
|
| 850 |
+
f'{_model_badge("MedSigLIP", "#388e3c")} '
|
| 851 |
+
f'{_model_badge("HeAR", "#f57c00")} '
|
| 852 |
+
f'{_model_badge("MedGemma", "#1976d2")}',
|
| 853 |
+
unsafe_allow_html=True,
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
# Pipeline diagram
|
| 857 |
+
st.markdown("""
|
| 858 |
+
<div style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0;">
|
| 859 |
+
<div style="background: #e3f2fd; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #1976d2;">Triage</div>
|
| 860 |
+
<span style="font-size: 1.5rem;">→</span>
|
| 861 |
+
<div style="background: #e8f5e9; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #388e3c;">Image (MedSigLIP)</div>
|
| 862 |
+
<span style="font-size: 1.5rem;">→</span>
|
| 863 |
+
<div style="background: #fff3e0; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #f57c00;">Audio (HeAR)</div>
|
| 864 |
+
<span style="font-size: 1.5rem;">→</span>
|
| 865 |
+
<div style="background: #f3e5f5; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #7b1fa2;">Protocol (WHO)</div>
|
| 866 |
+
<span style="font-size: 1.5rem;">→</span>
|
| 867 |
+
<div style="background: #fce4ec; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #c62828;">Referral</div>
|
| 868 |
+
<span style="font-size: 1.5rem;">→</span>
|
| 869 |
+
<div style="background: #e0f7fa; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #00838f;">Synthesis (MedGemma)</div>
|
| 870 |
+
</div>
|
| 871 |
+
""", unsafe_allow_html=True)
|
| 872 |
+
|
| 873 |
+
st.markdown("---")
|
| 874 |
+
|
| 875 |
+
# Input section
|
| 876 |
+
col_left, col_right = st.columns([1, 1])
|
| 877 |
+
|
| 878 |
+
with col_left:
|
| 879 |
+
st.subheader("Patient & Inputs")
|
| 880 |
+
patient_type = st.selectbox("Patient Type", ["newborn", "pregnant"], key="agentic_patient")
|
| 881 |
+
|
| 882 |
+
# Danger signs
|
| 883 |
+
st.markdown("**Danger Signs**")
|
| 884 |
+
danger_signs = []
|
| 885 |
+
if patient_type == "pregnant":
|
| 886 |
+
sign_options = [
|
| 887 |
+
("Severe headache", "high"),
|
| 888 |
+
("Blurred vision", "high"),
|
| 889 |
+
("Convulsions", "critical"),
|
| 890 |
+
("Severe abdominal pain", "high"),
|
| 891 |
+
("Vaginal bleeding", "critical"),
|
| 892 |
+
("High fever", "high"),
|
| 893 |
+
("Severe pallor", "medium"),
|
| 894 |
+
]
|
| 895 |
+
else:
|
| 896 |
+
sign_options = [
|
| 897 |
+
("Not breathing at birth", "critical"),
|
| 898 |
+
("Convulsions", "critical"),
|
| 899 |
+
("Severe chest indrawing", "high"),
|
| 900 |
+
("Not feeding", "high"),
|
| 901 |
+
("High fever (>38C)", "high"),
|
| 902 |
+
("Hypothermia (<35.5C)", "high"),
|
| 903 |
+
("Lethargy / unconscious", "critical"),
|
| 904 |
+
("Umbilical redness", "medium"),
|
| 905 |
+
]
|
| 906 |
+
|
| 907 |
+
selected_signs = st.multiselect(
|
| 908 |
+
"Select present danger signs",
|
| 909 |
+
[s[0] for s in sign_options],
|
| 910 |
+
key="agentic_signs"
|
| 911 |
+
)
|
| 912 |
+
for label, severity in sign_options:
|
| 913 |
+
if label in selected_signs:
|
| 914 |
+
danger_signs.append({
|
| 915 |
+
"id": label.lower().replace(" ", "_"),
|
| 916 |
+
"label": label,
|
| 917 |
+
"severity": severity,
|
| 918 |
+
"present": True,
|
| 919 |
+
})
|
| 920 |
+
|
| 921 |
+
# Image uploads
|
| 922 |
+
st.markdown("**Clinical Images**")
|
| 923 |
+
conjunctiva_file = st.file_uploader(
|
| 924 |
+
"Conjunctiva image (anemia)", type=["jpg", "jpeg", "png"],
|
| 925 |
+
key="agentic_conjunctiva"
|
| 926 |
+
)
|
| 927 |
+
skin_file = st.file_uploader(
|
| 928 |
+
"Skin image (jaundice)", type=["jpg", "jpeg", "png"],
|
| 929 |
+
key="agentic_skin"
|
| 930 |
+
)
|
| 931 |
+
cry_file = st.file_uploader(
|
| 932 |
+
"Cry audio", type=["wav", "mp3", "ogg"],
|
| 933 |
+
key="agentic_cry"
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
with col_right:
|
| 937 |
+
st.subheader("Workflow Execution")
|
| 938 |
+
|
| 939 |
+
if st.button("Run Agentic Assessment", type="primary", key="run_agentic"):
|
| 940 |
+
with st.spinner("Running 6-agent workflow..."):
|
| 941 |
+
try:
|
| 942 |
+
from nexus.agentic_workflow import (
|
| 943 |
+
AgenticWorkflowEngine,
|
| 944 |
+
AgentPatientInfo,
|
| 945 |
+
DangerSign,
|
| 946 |
+
WorkflowInput,
|
| 947 |
+
)
|
| 948 |
+
|
| 949 |
+
# Save uploaded files (track for cleanup)
|
| 950 |
+
_temp_paths = []
|
| 951 |
+
conjunctiva_path = None
|
| 952 |
+
skin_path = None
|
| 953 |
+
cry_path = None
|
| 954 |
+
|
| 955 |
+
if conjunctiva_file:
|
| 956 |
+
conjunctiva_path = _save_upload_to_temp(conjunctiva_file, ".jpg")
|
| 957 |
+
_temp_paths.append(conjunctiva_path)
|
| 958 |
+
|
| 959 |
+
if skin_file:
|
| 960 |
+
skin_path = _save_upload_to_temp(skin_file, ".jpg")
|
| 961 |
+
_temp_paths.append(skin_path)
|
| 962 |
+
|
| 963 |
+
if cry_file:
|
| 964 |
+
cry_path = _save_upload_to_temp(cry_file, ".wav")
|
| 965 |
+
_temp_paths.append(cry_path)
|
| 966 |
+
|
| 967 |
+
# Build workflow input
|
| 968 |
+
signs = [
|
| 969 |
+
DangerSign(
|
| 970 |
+
id=s["id"], label=s["label"],
|
| 971 |
+
severity=s["severity"], present=True,
|
| 972 |
+
)
|
| 973 |
+
for s in danger_signs
|
| 974 |
+
]
|
| 975 |
+
|
| 976 |
+
info = AgentPatientInfo(patient_type=patient_type)
|
| 977 |
+
workflow_input = WorkflowInput(
|
| 978 |
+
patient_type=patient_type,
|
| 979 |
+
patient_info=info,
|
| 980 |
+
danger_signs=signs,
|
| 981 |
+
conjunctiva_image=conjunctiva_path,
|
| 982 |
+
skin_image=skin_path,
|
| 983 |
+
cry_audio=cry_path,
|
| 984 |
+
)
|
| 985 |
+
|
| 986 |
+
# Run workflow — reuse cached model instances when available
|
| 987 |
+
anemia_det, _ = load_anemia_detector()
|
| 988 |
+
jaundice_det, _ = load_jaundice_detector()
|
| 989 |
+
cry_ana, _ = load_cry_analyzer()
|
| 990 |
+
synth, _ = load_clinical_synthesizer()
|
| 991 |
+
|
| 992 |
+
engine = AgenticWorkflowEngine(
|
| 993 |
+
anemia_detector=anemia_det,
|
| 994 |
+
jaundice_detector=jaundice_det,
|
| 995 |
+
cry_analyzer=cry_ana,
|
| 996 |
+
synthesizer=synth,
|
| 997 |
+
)
|
| 998 |
+
result = engine.execute(workflow_input)
|
| 999 |
+
|
| 1000 |
+
st.session_state["agentic_result"] = result
|
| 1001 |
+
st.success("Workflow complete!")
|
| 1002 |
+
|
| 1003 |
+
except Exception as e:
|
| 1004 |
+
st.error(f"Workflow error: {e}")
|
| 1005 |
+
finally:
|
| 1006 |
+
for p in _temp_paths:
|
| 1007 |
+
_cleanup_temp(p)
|
| 1008 |
+
|
| 1009 |
+
# Results display
|
| 1010 |
+
if "agentic_result" in st.session_state:
|
| 1011 |
+
result = st.session_state["agentic_result"]
|
| 1012 |
+
|
| 1013 |
+
st.markdown("---")
|
| 1014 |
+
|
| 1015 |
+
# Overall classification
|
| 1016 |
+
severity_colors = {
|
| 1017 |
+
"GREEN": ("#d4edda", "#155724", "Routine care"),
|
| 1018 |
+
"YELLOW": ("#fff3cd", "#856404", "Close monitoring"),
|
| 1019 |
+
"RED": ("#f8d7da", "#721c24", "Urgent referral"),
|
| 1020 |
+
}
|
| 1021 |
+
bg, fg, desc = severity_colors.get(result.who_classification, ("#f8f9fa", "#000", "Unknown"))
|
| 1022 |
+
|
| 1023 |
+
st.markdown(f"""
|
| 1024 |
+
<div style="background: {bg}; color: {fg}; padding: 1.5rem; border-radius: 10px; text-align: center; margin: 1rem 0;">
|
| 1025 |
+
<h2 style="margin: 0;">WHO Classification: {result.who_classification}</h2>
|
| 1026 |
+
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem;">{desc}</p>
|
| 1027 |
+
</div>
|
| 1028 |
+
""", unsafe_allow_html=True)
|
| 1029 |
+
|
| 1030 |
+
# Key metrics
|
| 1031 |
+
m1, m2, m3, m4 = st.columns(4)
|
| 1032 |
+
with m1:
|
| 1033 |
+
st.metric("Agents Run", len(result.agent_traces))
|
| 1034 |
+
with m2:
|
| 1035 |
+
st.metric("Total Time", f"{result.processing_time_ms:.0f} ms")
|
| 1036 |
+
with m3:
|
| 1037 |
+
referral_text = "Yes" if (result.referral_result and result.referral_result.referral_needed) else "No"
|
| 1038 |
+
st.metric("Referral Needed", referral_text)
|
| 1039 |
+
with m4:
|
| 1040 |
+
triage_score = result.triage_result.score if result.triage_result else 0
|
| 1041 |
+
st.metric("Triage Score", triage_score)
|
| 1042 |
+
|
| 1043 |
+
# Clinical synthesis
|
| 1044 |
+
st.subheader("Clinical Synthesis")
|
| 1045 |
+
st.info(result.clinical_synthesis)
|
| 1046 |
+
|
| 1047 |
+
if result.immediate_actions:
|
| 1048 |
+
st.subheader("Immediate Actions")
|
| 1049 |
+
for action in result.immediate_actions:
|
| 1050 |
+
st.markdown(f"- {action}")
|
| 1051 |
+
|
| 1052 |
+
# Visual pipeline flow with status indicators
|
| 1053 |
+
st.markdown("---")
|
| 1054 |
+
st.subheader("Agent Pipeline Execution")
|
| 1055 |
+
|
| 1056 |
+
agent_meta = {
|
| 1057 |
+
"TriageAgent": {"color": "#1976d2", "bg": "#e3f2fd", "icon": "1", "label": "Triage"},
|
| 1058 |
+
"ImageAnalysisAgent": {"color": "#388e3c", "bg": "#e8f5e9", "icon": "2", "label": "Image (MedSigLIP)"},
|
| 1059 |
+
"AudioAnalysisAgent": {"color": "#f57c00", "bg": "#fff3e0", "icon": "3", "label": "Audio (HeAR)"},
|
| 1060 |
+
"ProtocolAgent": {"color": "#7b1fa2", "bg": "#f3e5f5", "icon": "4", "label": "WHO Protocol"},
|
| 1061 |
+
"ReferralAgent": {"color": "#c62828", "bg": "#fce4ec", "icon": "5", "label": "Referral"},
|
| 1062 |
+
"SynthesisAgent": {"color": "#00838f", "bg": "#e0f7fa", "icon": "6", "label": "Synthesis (MedGemma)"},
|
| 1063 |
+
}
|
| 1064 |
+
status_symbols = {"success": "OK", "skipped": "SKIP", "error": "ERR"}
|
| 1065 |
+
|
| 1066 |
+
# Build trace lookup
|
| 1067 |
+
trace_lookup = {t.agent_name: t for t in result.agent_traces}
|
| 1068 |
+
|
| 1069 |
+
# Pipeline status bar
|
| 1070 |
+
pipeline_html_parts = []
|
| 1071 |
+
for agent_name, meta in agent_meta.items():
|
| 1072 |
+
trace = trace_lookup.get(agent_name)
|
| 1073 |
+
if trace:
|
| 1074 |
+
status_sym = status_symbols.get(trace.status, "?")
|
| 1075 |
+
opacity = "1.0" if trace.status == "success" else "0.5"
|
| 1076 |
+
border_style = f"3px solid {meta['color']}" if trace.status == "success" else "2px dashed #999"
|
| 1077 |
+
time_label = f"{trace.processing_time_ms:.0f}ms"
|
| 1078 |
+
else:
|
| 1079 |
+
status_sym = "---"
|
| 1080 |
+
opacity = "0.3"
|
| 1081 |
+
border_style = "2px dashed #ccc"
|
| 1082 |
+
time_label = ""
|
| 1083 |
+
|
| 1084 |
+
pipeline_html_parts.append(f"""
|
| 1085 |
+
<div style="background: {meta['bg']}; padding: 0.4rem 0.7rem; border-radius: 8px;
|
| 1086 |
+
border: {border_style}; opacity: {opacity}; text-align: center; min-width: 90px;">
|
| 1087 |
+
<div style="font-weight: bold; font-size: 0.8rem; color: {meta['color']};">{meta['label']}</div>
|
| 1088 |
+
<div style="font-size: 0.7rem; color: #666;">{status_sym} {time_label}</div>
|
| 1089 |
+
</div>
|
| 1090 |
+
""")
|
| 1091 |
+
|
| 1092 |
+
pipeline_html = '<div style="display: flex; align-items: center; justify-content: center; gap: 0.3rem; flex-wrap: wrap; margin: 0.5rem 0;">'
|
| 1093 |
+
for i, part in enumerate(pipeline_html_parts):
|
| 1094 |
+
pipeline_html += part
|
| 1095 |
+
if i < len(pipeline_html_parts) - 1:
|
| 1096 |
+
pipeline_html += '<span style="font-size: 1.2rem; color: #999;">→</span>'
|
| 1097 |
+
pipeline_html += "</div>"
|
| 1098 |
+
st.markdown(pipeline_html, unsafe_allow_html=True)
|
| 1099 |
+
|
| 1100 |
+
# Agent reasoning traces (key feature for Agentic Workflow prize)
|
| 1101 |
+
st.markdown("---")
|
| 1102 |
+
st.subheader("Agent Reasoning Traces")
|
| 1103 |
+
|
| 1104 |
+
for trace in result.agent_traces:
|
| 1105 |
+
meta = agent_meta.get(trace.agent_name, {"color": "#666", "bg": "#f5f5f5", "label": trace.agent_name})
|
| 1106 |
+
status_emoji = {"success": "OK", "skipped": "SKIP", "error": "ERR"}.get(trace.status, "?")
|
| 1107 |
+
|
| 1108 |
+
header_label = f"{meta['label']} [{status_emoji}] - {trace.confidence:.0%} confidence - {trace.processing_time_ms:.0f}ms"
|
| 1109 |
+
with st.expander(header_label, expanded=(trace.status == "success")):
|
| 1110 |
+
# Status bar
|
| 1111 |
+
st.markdown(f"""
|
| 1112 |
+
<div style="background: {meta['bg']}; padding: 0.8rem 1rem; border-radius: 8px;
|
| 1113 |
+
border-left: 4px solid {meta['color']}; margin-bottom: 0.5rem;">
|
| 1114 |
+
<strong style="color: {meta['color']};">{trace.agent_name}</strong> |
|
| 1115 |
+
Status: <strong>{trace.status}</strong> |
|
| 1116 |
+
Confidence: <strong>{trace.confidence:.1%}</strong> |
|
| 1117 |
+
Time: <strong>{trace.processing_time_ms:.1f}ms</strong>
|
| 1118 |
+
</div>
|
| 1119 |
+
""", unsafe_allow_html=True)
|
| 1120 |
+
|
| 1121 |
+
# Reasoning steps with numbered styling
|
| 1122 |
+
if trace.reasoning:
|
| 1123 |
+
st.markdown("**Reasoning Chain:**")
|
| 1124 |
+
for i, step in enumerate(trace.reasoning, 1):
|
| 1125 |
+
st.markdown(f"**Step {i}.** {step}")
|
| 1126 |
+
|
| 1127 |
+
# Key findings
|
| 1128 |
+
if trace.findings:
|
| 1129 |
+
st.markdown("**Key Findings:**")
|
| 1130 |
+
st.json(trace.findings)
|
| 1131 |
+
|
| 1132 |
+
# Processing time breakdown
|
| 1133 |
+
st.markdown("---")
|
| 1134 |
+
col_chart, col_summary = st.columns([2, 1])
|
| 1135 |
+
|
| 1136 |
+
with col_chart:
|
| 1137 |
+
st.subheader("Processing Time by Agent")
|
| 1138 |
+
import pandas as pd
|
| 1139 |
+
chart_data = pd.DataFrame({
|
| 1140 |
+
"Agent": [agent_meta.get(t.agent_name, {}).get("label", t.agent_name) for t in result.agent_traces],
|
| 1141 |
+
"Time (ms)": [t.processing_time_ms for t in result.agent_traces],
|
| 1142 |
+
})
|
| 1143 |
+
st.bar_chart(chart_data.set_index("Agent"))
|
| 1144 |
+
|
| 1145 |
+
with col_summary:
|
| 1146 |
+
st.subheader("Workflow Summary")
|
| 1147 |
+
total_time = result.processing_time_ms
|
| 1148 |
+
successful = sum(1 for t in result.agent_traces if t.status == "success")
|
| 1149 |
+
skipped = sum(1 for t in result.agent_traces if t.status == "skipped")
|
| 1150 |
+
errors = sum(1 for t in result.agent_traces if t.status == "error")
|
| 1151 |
+
st.markdown(f"""
|
| 1152 |
+
| Metric | Value |
|
| 1153 |
+
|--------|-------|
|
| 1154 |
+
| Total agents | {len(result.agent_traces)} |
|
| 1155 |
+
| Successful | {successful} |
|
| 1156 |
+
| Skipped | {skipped} |
|
| 1157 |
+
| Errors | {errors} |
|
| 1158 |
+
| Total time | {total_time:.0f} ms |
|
| 1159 |
+
| Avg per agent | {total_time / max(len(result.agent_traces), 1):.0f} ms |
|
| 1160 |
+
""")
|
| 1161 |
+
|
| 1162 |
+
# Referral details
|
| 1163 |
+
if result.referral_result and result.referral_result.referral_needed:
|
| 1164 |
+
st.markdown("---")
|
| 1165 |
+
st.subheader("Referral Details")
|
| 1166 |
+
ref = result.referral_result
|
| 1167 |
+
r1, r2, r3 = st.columns(3)
|
| 1168 |
+
with r1:
|
| 1169 |
+
st.metric("Urgency", ref.urgency.upper())
|
| 1170 |
+
with r2:
|
| 1171 |
+
st.metric("Facility", ref.facility_level.title())
|
| 1172 |
+
with r3:
|
| 1173 |
+
st.metric("Timeframe", ref.timeframe)
|
| 1174 |
+
st.warning(f"Reason: {ref.reason}")
|
| 1175 |
+
|
| 1176 |
+
|
| 1177 |
+
# Footer
|
| 1178 |
+
def render_footer():
|
| 1179 |
+
"""Render footer."""
|
| 1180 |
+
st.markdown("---")
|
| 1181 |
+
st.markdown("""
|
| 1182 |
+
<div style="text-align: center; color: #666; font-size: 0.9rem;">
|
| 1183 |
+
<p>NEXUS - Built with Google HAI-DEF for MedGemma Impact Challenge 2026</p>
|
| 1184 |
+
<p>⚠️ This is a screening tool only. Always confirm with laboratory tests.</p>
|
| 1185 |
+
</div>
|
| 1186 |
+
""", unsafe_allow_html=True)
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
if __name__ == "__main__":
|
| 1190 |
+
main()
|
| 1191 |
+
render_footer()
|