HeartWatchAI / app.py
Ashkan Taghipour (The University of Western Australia)
Revert "Full dark mode theme with mobile layout fixes"
7a03d92
"""
HeartWatch AI - ECG Analysis Demo
==================================
A Gradio-based web application for AI-powered ECG analysis using DeepECG models.
Features:
- 77-class ECG diagnosis
- LVEF < 40% prediction
- LVEF < 50% prediction
- 5-year AFib risk assessment
- Interactive 12-lead ECG visualization
"""
import os
import logging
import numpy as np
import gradio as gr
from pathlib import Path
# Local imports
from inference import DeepECGInference
from visualization import (
plot_ecg_waveform,
plot_diagnosis_bars,
plot_risk_gauges,
)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Global inference engine
inference_engine = None
# Sample ECG descriptions - mapped by file stem (with underscores replaced by spaces and title-cased)
# The files are: Atrial_Flutter.npy, Normal_Sinus_Rhythm.npy, Ventricular_Tachycardia.npy
# They get sorted alphabetically: Atrial Flutter, Normal Sinus Rhythm, Ventricular Tachycardia
# We want to display them as Sample 1, Sample 2, Sample 3
SAMPLE_FILE_TO_DISPLAY = {
"Atrial Flutter": "Sample 1",
"Normal Sinus Rhythm": "Sample 2",
"Ventricular Tachycardia": "Sample 3",
}
SAMPLE_DESCRIPTIONS = {
"Sample 1": "Atrial Flutter - A rapid but regular atrial rhythm, typically around 250-350 bpm in the atria.",
"Sample 2": "Normal Sinus Rhythm - A healthy heart rhythm with regular beats originating from the sinus node.",
"Sample 3": "Ventricular Tachycardia - A fast heart rhythm originating from the ventricles, potentially life-threatening.",
}
# Reverse mapping: display name to real condition info for analysis results
DISPLAY_TO_CONDITION = {
"Sample 1": {
"name": "Atrial Flutter",
"description": "A rapid but regular atrial rhythm, typically around 250-350 bpm in the atria."
},
"Sample 2": {
"name": "Normal Sinus Rhythm",
"description": "A healthy heart rhythm with regular beats originating from the sinus node."
},
"Sample 3": {
"name": "Ventricular Tachycardia",
"description": "A fast heart rhythm originating from the ventricles, potentially life-threatening."
},
}
def load_inference_engine():
"""Load the inference engine on startup."""
global inference_engine
if inference_engine is None:
logger.info("Loading DeepECG inference engine...")
inference_engine = DeepECGInference()
inference_engine.load_models()
logger.info("Inference engine loaded successfully")
return inference_engine
def get_sample_ecgs():
"""Get list of sample ECG files from demo_data directory."""
sample_dir = Path(__file__).parent / "demo_data" / "samples"
if not sample_dir.exists():
logger.warning(f"Sample directory not found: {sample_dir}")
return []
samples = []
for npy_file in sorted(sample_dir.glob("*.npy")):
original_name = npy_file.stem.replace("_", " ").title()
# Map to new display name (Sample 1, Sample 2, Sample 3)
display_name = SAMPLE_FILE_TO_DISPLAY.get(original_name, original_name)
samples.append({
"path": str(npy_file),
"name": display_name,
"original_name": original_name,
"description": SAMPLE_DESCRIPTIONS.get(display_name, "Sample ECG recording")
})
logger.info(f"Found {len(samples)} sample ECGs")
return samples
def analyze_ecg(ecg_signal: np.ndarray, filename: str = "ECG Analysis", condition_info: dict = None):
"""
Analyze an ECG signal and return all visualizations.
Args:
ecg_signal: ECG signal array
filename: Name to display
condition_info: Optional dict with 'name' and 'description' for the condition
Returns:
Tuple of (ecg_plot, diagnosis_plot, risk_plot, summary_text)
"""
engine = load_inference_engine()
# Run inference
results = engine.predict(ecg_signal)
# Generate ECG waveform plot
ecg_fig = plot_ecg_waveform(ecg_signal, sample_rate=250, title=filename)
# Generate diagnosis bar chart
if "diagnosis_77" in results:
probs = results["diagnosis_77"]["probabilities"]
class_names = results["diagnosis_77"]["class_names"]
diagnosis_dict = dict(zip(class_names, probs))
diagnosis_fig = plot_diagnosis_bars(diagnosis_dict, top_n=10)
else:
diagnosis_fig = None
# Generate risk gauges
lvef_40 = results.get("lvef_40", 0.0)
lvef_50 = results.get("lvef_50", 0.0)
afib_5y = results.get("afib_5y", 0.0)
risk_fig = plot_risk_gauges(lvef_40, lvef_50, afib_5y)
# Generate modern HTML summary with styled diagnosis cards
inference_time = results.get("inference_time_ms", 0)
# Build the diagnosis cards HTML with modern dark theme design
diagnosis_html = '<div class="diagnosis-dashboard-title">Top 5 Diagnoses</div>'
if "diagnosis_77" in results:
probs = results["diagnosis_77"]["probabilities"]
class_names = results["diagnosis_77"]["class_names"]
top_indices = np.argsort(probs)[::-1][:5]
for i, idx in enumerate(top_indices, 1):
prob_pct = probs[idx] * 100
# Determine severity class based on probability
if prob_pct < 30:
severity_class = "severity-low"
elif prob_pct < 60:
severity_class = "severity-medium"
else:
severity_class = "severity-high"
# Create smooth gradient progress bar (no segments)
diagnosis_html += f"""
<div class="diagnosis-row {severity_class}">
<span class="diagnosis-rank">#{i}</span>
<span class="diagnosis-name" title="{class_names[idx]}">{class_names[idx]}</span>
<div class="diagnosis-bar-container">
<div class="diagnosis-bar-track">
<div class="diagnosis-bar-fill" style="width: {prob_pct}%;"></div>
</div>
</div>
<span class="diagnosis-percent">{prob_pct:.0f}%</span>
</div>
"""
# Determine display title and description
if condition_info:
display_title = condition_info.get("name", filename)
condition_desc = condition_info.get("description", "")
condition_html = f'<p style="color: #666; font-size: 0.95em; margin: 8px 0 16px 0; font-style: italic;">{condition_desc}</p>' if condition_desc else ""
else:
display_title = filename
condition_html = ""
summary = f"""
<div style="padding: 10px; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;">
<h2 style="margin: 0 0 8px 0; color: #333;">Analysis Results: {display_title}</h2>
{condition_html}
<div style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 8px 16px; border-radius: 20px; font-size: 0.9em; margin-bottom: 20px;">
Inference Time: {inference_time:.1f} ms
</div>
<h3 style="margin: 20px 0 12px 0; color: #444;">Risk Predictions</h3>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px; background: #f8f9fa; border-radius: 8px; overflow: hidden;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 12px 16px; text-align: left; font-weight: 600;">Risk Factor</th>
<th style="padding: 12px 16px; text-align: left; font-weight: 600;">Probability</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid #e9ecef;">
<td style="padding: 12px 16px;">LVEF &lt; 40%</td>
<td style="padding: 12px 16px; font-weight: 600;">{lvef_40*100:.1f}%</td>
</tr>
<tr style="border-bottom: 1px solid #e9ecef;">
<td style="padding: 12px 16px;">LVEF &lt; 50%</td>
<td style="padding: 12px 16px; font-weight: 600;">{lvef_50*100:.1f}%</td>
</tr>
<tr>
<td style="padding: 12px 16px;">5-year AFib Risk</td>
<td style="padding: 12px 16px; font-weight: 600;">{afib_5y*100:.1f}%</td>
</tr>
</tbody>
</table>
<div class="diagnosis-dashboard">
{diagnosis_html}
</div>
</div>
"""
return ecg_fig, diagnosis_fig, risk_fig, summary
def analyze_uploaded_file(file):
"""Handle uploaded .npy file."""
if file is None:
return None, None, None, "<p style='color: #666;'>Please upload a .npy file containing ECG data.</p>"
try:
# In Gradio 4.x with type="filepath", file is a string path
file_path = file if isinstance(file, str) else file.name
ecg_signal = np.load(file_path)
filename = Path(file_path).stem.replace("_", " ").title()
return analyze_ecg(ecg_signal, filename)
except Exception as e:
logger.error(f"Error loading file: {e}")
return None, None, None, f"<p style='color: #dc3545;'>Error loading file: {str(e)}</p>"
def analyze_sample_by_name(sample_name: str):
"""Analyze a sample ECG by its name."""
if not sample_name:
return None, None, None, "<p style='color: #666;'>Please select a sample ECG.</p>"
samples = get_sample_ecgs()
for sample in samples:
if sample["name"] == sample_name:
try:
ecg_signal = np.load(sample["path"])
# Get the real condition info for display
condition_info = DISPLAY_TO_CONDITION.get(sample_name)
return analyze_ecg(ecg_signal, sample["name"], condition_info)
except Exception as e:
logger.error(f"Error loading sample: {e}")
return None, None, None, f"<p style='color: #dc3545;'>Error loading sample: {str(e)}</p>"
return None, None, None, "<p style='color: #dc3545;'>Sample not found.</p>"
def create_demo_interface():
"""Create the Gradio interface."""
# Get samples at startup
samples = get_sample_ecgs()
sample_names = [s["name"] for s in samples]
# Custom CSS for styling with modern animated header
custom_css = """
.gradio-container {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Animated Header Styles */
.main-header {
text-align: center;
padding: 40px 24px;
background: linear-gradient(-45deg, #ee7752, #e73c7e, #c0392b, #e74c3c);
background-size: 400% 400%;
animation: gradientShift 8s ease infinite;
color: white;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 10px 40px rgba(231, 76, 60, 0.3);
position: relative;
overflow: hidden;
}
.main-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1) 0%, transparent 50%);
pointer-events: none;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.header-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* Pulsing Heart Container */
.heart-container {
position: relative;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
/* Heart SVG Animation */
.heart-svg {
width: 80px;
height: 80px;
animation: heartbeat 1.2s ease-in-out infinite;
filter: drop-shadow(0 0 20px rgba(255,255,255,0.5));
}
@keyframes heartbeat {
0% { transform: scale(1); }
14% { transform: scale(1.15); }
28% { transform: scale(1); }
42% { transform: scale(1.1); }
70% { transform: scale(1); }
}
/* ECG Line Animation */
.ecg-line {
position: absolute;
width: 200px;
height: 40px;
left: 50%;
transform: translateX(-50%);
bottom: -10px;
}
.ecg-path {
stroke: rgba(255,255,255,0.8);
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: ecgDraw 2s ease-in-out infinite;
}
@keyframes ecgDraw {
0% { stroke-dashoffset: 200; opacity: 0; }
10% { opacity: 1; }
50% { stroke-dashoffset: 0; opacity: 1; }
90% { opacity: 1; }
100% { stroke-dashoffset: -200; opacity: 0; }
}
.main-header h1 {
margin: 0;
font-size: 2.8em;
font-weight: 700;
letter-spacing: -0.02em;
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.main-header p {
margin: 0;
opacity: 0.95;
font-size: 1.2em;
font-weight: 400;
letter-spacing: 0.02em;
}
.sample-card {
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
margin: 8px 0;
border-left: 4px solid #e74c3c;
}
.quick-start {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
padding: 18px 20px;
border-radius: 12px;
margin: 20px 0;
border-left: 5px solid #4caf50;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.15);
}
/* Dark Theme Diagnosis Dashboard */
.diagnosis-dashboard {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 16px;
padding: 24px;
margin-top: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.diagnosis-dashboard-title {
color: #ffffff;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}
.diagnosis-row {
display: flex;
align-items: center;
padding: 12px 16px;
margin: 8px 0;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.diagnosis-row:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateX(4px);
}
.diagnosis-rank {
font-size: 0.9em;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
width: 36px;
flex-shrink: 0;
}
.diagnosis-name {
font-size: 0.95em;
font-weight: 500;
color: #ffffff;
min-width: 120px;
max-width: 180px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.diagnosis-bar-container {
flex: 1;
display: flex;
align-items: center;
margin: 0 16px;
min-width: 80px;
}
.diagnosis-bar-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
position: relative;
overflow: hidden;
}
.diagnosis-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
/* Animated shine effect on bars */
.diagnosis-bar-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shine 2s ease-in-out infinite;
}
@keyframes shine {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.diagnosis-percent {
font-size: 0.9em;
font-weight: 700;
width: 55px;
text-align: right;
flex-shrink: 0;
text-shadow: 0 0 10px currentColor;
}
/* Color classes for severity with glow effects */
.severity-low .diagnosis-bar-fill {
background: linear-gradient(90deg, #00c853 0%, #69f0ae 100%);
box-shadow: 0 0 12px rgba(0, 200, 83, 0.5), 0 0 4px rgba(0, 200, 83, 0.3);
}
.severity-low .diagnosis-percent {
color: #69f0ae;
}
.severity-medium .diagnosis-bar-fill {
background: linear-gradient(90deg, #ff9800 0%, #ffc107 100%);
box-shadow: 0 0 12px rgba(255, 152, 0, 0.5), 0 0 4px rgba(255, 152, 0, 0.3);
}
.severity-medium .diagnosis-percent {
color: #ffc107;
}
.severity-high .diagnosis-bar-fill {
background: linear-gradient(90deg, #f44336 0%, #ff5252 100%);
box-shadow: 0 0 12px rgba(244, 67, 54, 0.5), 0 0 4px rgba(244, 67, 54, 0.3);
}
.severity-high .diagnosis-percent {
color: #ff5252;
}
/* Responsive Design for Diagnosis Dashboard */
@media (max-width: 768px) {
.diagnosis-dashboard {
padding: 16px;
border-radius: 12px;
}
.diagnosis-row {
padding: 10px 12px;
flex-wrap: wrap;
}
.diagnosis-rank {
width: 28px;
font-size: 0.85em;
}
.diagnosis-name {
flex: 1;
min-width: 100px;
max-width: none;
font-size: 0.9em;
}
.diagnosis-bar-container {
order: 3;
width: 100%;
margin: 8px 0 0 0;
flex-basis: 100%;
}
.diagnosis-percent {
width: auto;
margin-left: auto;
font-size: 0.85em;
}
}
@media (max-width: 480px) {
.diagnosis-dashboard {
padding: 12px;
margin-top: 4px;
}
.diagnosis-dashboard-title {
font-size: 0.75em;
margin-bottom: 12px;
padding-bottom: 8px;
}
.diagnosis-row {
padding: 8px 10px;
margin: 6px 0;
}
.diagnosis-rank {
width: 24px;
font-size: 0.8em;
}
.diagnosis-name {
font-size: 0.85em;
}
.diagnosis-percent {
font-size: 0.8em;
}
.diagnosis-bar-track {
height: 5px;
}
}
/* Footer Styles */
.footer-container {
margin-top: 40px;
padding: 30px;
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
border-radius: 16px;
color: white;
text-align: center;
}
.footer-content {
max-width: 800px;
margin: 0 auto;
}
.footer-acknowledgement {
font-size: 1em;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.2);
}
.footer-acknowledgement a {
color: #3498db;
text-decoration: none;
font-weight: 600;
}
.footer-acknowledgement a:hover {
text-decoration: underline;
}
.footer-disclaimer {
font-size: 0.9em;
color: rgba(255,255,255,0.7);
padding: 12px 20px;
background: rgba(231, 76, 60, 0.2);
border-radius: 8px;
border: 1px solid rgba(231, 76, 60, 0.3);
}
.footer-disclaimer strong {
color: #e74c3c;
}
"""
with gr.Blocks(css=custom_css, title="HeartWatch AI", theme=gr.themes.Soft()) as demo:
# Animated Header with Pulsing Heart
gr.HTML("""
<div class="main-header">
<div class="header-content">
<div class="heart-container">
<svg class="heart-svg" viewBox="0 0 32 29.6">
<path fill="white" d="M23.6,0c-3.4,0-6.3,2.7-7.6,5.6C14.7,2.7,11.8,0,8.4,0C3.8,0,0,3.8,0,8.4c0,9.4,9.5,11.9,16,21.2c6.1-9.3,16-12.1,16-21.2C32,3.8,28.2,0,23.6,0z"/>
</svg>
<svg class="ecg-line" viewBox="0 0 200 40">
<path class="ecg-path" d="M0,20 L40,20 L50,20 L55,5 L60,35 L65,10 L70,25 L75,20 L120,20 L130,20 L135,8 L140,32 L145,12 L150,24 L155,20 L200,20"/>
</svg>
</div>
<h1>HeartWatch AI</h1>
<p>AI-Powered 12-Lead ECG Analysis</p>
</div>
</div>
""")
# Quick start notice
gr.HTML("""
<div class="quick-start">
<strong>🚀 Quick Start:</strong> Select a sample ECG below and click "Analyze" to see the AI analysis instantly!
</div>
""")
with gr.Tabs() as tabs:
# Tab 1: Try Sample ECGs (DEFAULT - First Tab)
with gr.TabItem("🎯 Try Sample ECGs", id=0):
gr.Markdown("""
### Select a Sample ECG
Choose from our collection of real ECG recordings to see the AI analysis in action.
""")
with gr.Row():
with gr.Column(scale=1):
# Sample selection with radio buttons for better UX
if sample_names:
sample_radio = gr.Radio(
choices=sample_names,
value=sample_names[0],
label="Available ECG Samples",
info="Click on a sample to select it"
)
analyze_sample_btn = gr.Button(
"🔍 Analyze Selected ECG",
variant="primary",
size="lg"
)
else:
gr.Markdown("⚠️ No sample ECGs found. Please use the Upload tab.")
sample_radio = gr.Radio(choices=[], label="No samples available")
analyze_sample_btn = gr.Button("Analyze", interactive=False)
with gr.Column(scale=2):
sample_summary = gr.HTML(
value="<p>👆 Select a sample and click <strong>Analyze</strong> to see results.</p>",
label="Analysis Summary"
)
with gr.Row():
sample_ecg_plot = gr.Plot(label="12-Lead ECG Waveform")
with gr.Row():
with gr.Column():
sample_diagnosis_plot = gr.Plot(label="Diagnosis Probabilities")
with gr.Column():
sample_risk_plot = gr.Plot(label="Risk Assessment Gauges")
if sample_names:
analyze_sample_btn.click(
fn=analyze_sample_by_name,
inputs=[sample_radio],
outputs=[sample_ecg_plot, sample_diagnosis_plot, sample_risk_plot, sample_summary]
)
# Tab 2: Upload Your Own ECG
with gr.TabItem("📤 Upload Your ECG", id=1):
gr.Markdown("""
### Upload Your Own ECG Recording
Have your own ECG data? Upload it here for analysis.
""")
with gr.Row():
with gr.Column(scale=1):
file_input = gr.File(
label="Upload ECG File (.npy)",
file_types=[".npy"],
type="filepath"
)
analyze_btn = gr.Button(
"🔍 Analyze Uploaded ECG",
variant="primary",
size="lg"
)
gr.Markdown("""
**Expected Format:**
- **File type:** NumPy array (.npy)
- **Shape:** (2500, 12) or (12, 2500)
- **Leads:** I, II, III, aVR, aVL, aVF, V1-V6
- **Duration:** 10 seconds at 250 Hz
**Tip:** Use `numpy.save('ecg.npy', signal)` to create compatible files.
""")
with gr.Column(scale=2):
upload_summary = gr.HTML(
value="<p>👆 Upload a .npy file and click <strong>Analyze</strong> to see results.</p>",
label="Summary"
)
with gr.Row():
upload_ecg_plot = gr.Plot(label="12-Lead ECG Waveform")
with gr.Row():
with gr.Column():
upload_diagnosis_plot = gr.Plot(label="Diagnosis Probabilities")
with gr.Column():
upload_risk_plot = gr.Plot(label="Risk Assessment Gauges")
analyze_btn.click(
fn=analyze_uploaded_file,
inputs=[file_input],
outputs=[upload_ecg_plot, upload_diagnosis_plot, upload_risk_plot, upload_summary]
)
# Tab 3: About
with gr.TabItem("ℹ️ About", id=2):
gr.Markdown("""
## About HeartWatch AI
HeartWatch AI is a deep learning-based ECG analysis system powered by state-of-the-art models.
### 🧠 AI Models
| Model | Description |
|-------|-------------|
| **77-Class Diagnosis** | Detects 77 different ECG patterns and cardiac conditions |
| **LVEF < 40%** | Predicts reduced left ventricular ejection fraction |
| **LVEF < 50%** | Predicts moderately reduced ejection fraction |
| **5-Year AFib Risk** | Estimates risk of developing Atrial Fibrillation |
### 📊 Technical Details
- **Architecture:** EfficientNetV2 (TorchScript optimized)
- **Input:** 12-lead ECG, 10 seconds, 250 Hz
- **Inference:** CPU-optimized for accessibility
- **Training Data:** Large clinical ECG datasets
### ⚠️ Important Disclaimer
**This is a research demonstration tool.**
The predictions provided should **NOT** be used for clinical decision-making.
Always consult qualified healthcare professionals for medical advice and diagnosis.
### 📚 References
- Models based on the DeepECG project
- Sample ECGs from MIT-BIH Arrhythmia Database (PhysioNet)
---
*Built with Gradio and PyTorch*
""")
# Modern Footer with Acknowledgement and Disclaimer
gr.HTML("""
<div class="footer-container">
<div class="footer-content">
<div class="footer-acknowledgement">
Based on <a href="https://github.com/HeartWise-AI/DeepECG_Docker" target="_blank">HeartWise-AI/DeepECG_Docker</a>
</div>
<div class="footer-disclaimer">
<strong>Disclaimer:</strong> This is a research demo. Not for clinical use.
</div>
</div>
</div>
""")
return demo
# Create and launch the demo
if __name__ == "__main__":
# Create and launch demo
demo = create_demo_interface()
demo.launch(
server_name="0.0.0.0",
server_port=7860,
share=False
)