File size: 16,966 Bytes
e4d73f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 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 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 | # utils/page_classifier.py
"""
Page 3: ECG Classification.
Handles CSV upload and pre-trained model inference.
"""
import os
import tempfile
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import streamlit as st
def render(config, classification_runner):
"""Render the Classification page."""
# Page heading
st.markdown(
'<h1 style="font-weight: 800; letter-spacing: -0.5px;">โค๏ธ <span class="glow-text">ECG Classification</span></h1>',
unsafe_allow_html=True
)
st.markdown(
'<p class="section-subtitle">'
'Run diagnostic classification tasks (MI vs Normal, OMI vs non-OMI, Pre vs Post-Procedural MI) '
'using advanced pre-trained models.'
'</p>',
unsafe_allow_html=True
)
# Check dependencies before proceeding
dep_ok = True
try:
classification_runner.check_classification_dependencies()
except classification_runner.DependencyMissingError as e:
st.warning(str(e))
st.info(
"The application will show layout placeholders. "
"Install the packages in your terminal and restart/refresh to activate."
)
dep_ok = False
# 2-Column layout: Left panel (Controls), Right panel (Execution Steps & Results)
col1, col2 = st.columns([2, 3])
with col1:
# 1. Upload section
csv_to_use = _render_upload(config)
# 2. Configuration & run button
run_class_btn, selected_task, task_meta = _render_controls(config, classification_runner, csv_to_use, dep_ok)
with col2:
# Placeholder for real-time progress of running phases
status_placeholder = st.empty()
if run_class_btn:
if not csv_to_use:
st.error("Please upload a CSV dataset first.")
else:
_run_classification(config, classification_runner, csv_to_use, task_meta, selected_task, status_placeholder)
# 3. Show results in the right panel if classification is completed and matches selected task
if ("clf_results" in st.session_state and
st.session_state.get("selected_task") == selected_task):
_show_results(task_meta, selected_task)
elif not run_class_btn:
# Show a helpful guide card when idle
_show_idle_guide()
def _render_upload(config):
"""Render the CSV upload section. Returns the path to the CSV file or None.
Caches the temp file path in session state so we don't re-write the
same bytes to disk on every Streamlit rerun.
"""
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### ๐ค Upload ECG Signals Dataset")
uploaded_csv = st.file_uploader(
"Select digitized ECG CSV dataset (multi-subject format with lead names as columns)",
type=["csv"],
key="classifier_csv_upload"
)
st.markdown('</div>', unsafe_allow_html=True)
csv_to_use = None
if uploaded_csv is not None:
# Only write to disk if this is a new / different file
prev_name = st.session_state.get("_clf_upload_name")
prev_path = st.session_state.get("_clf_upload_path")
if prev_name == uploaded_csv.name and prev_path and os.path.exists(prev_path):
csv_to_use = prev_path
else:
tfile = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
tfile.write(uploaded_csv.read())
tfile.close()
csv_to_use = tfile.name
st.session_state["_clf_upload_name"] = uploaded_csv.name
st.session_state["_clf_upload_path"] = csv_to_use
else:
# Clear cached path when file is removed
st.session_state.pop("_clf_upload_name", None)
st.session_state.pop("_clf_upload_path", None)
if csv_to_use:
_render_dataset_preview(csv_to_use)
return csv_to_use
@st.cache_data(show_spinner="Loading dataset preview...")
def _load_preview_data(csv_path: str, _mtime: float):
"""Load the CSV once and return all data needed for the preview.
Cached on the file path + its mtime so it invalidates when the file changes.
Returns (head_df, row_count, leads_found, class_dist_or_none).
"""
df = pd.read_csv(csv_path)
row_count = len(df)
leads_found = [col for col in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF',
'V1', 'V2', 'V3', 'V4', 'V5', 'V6']
if col in df.columns]
class_dist = None
if 'class' in df.columns:
class_dist = df['class'].value_counts().to_dict()
return df.head(10), row_count, leads_found, class_dist
def _render_dataset_preview(csv_path):
"""Show a preview of the loaded dataset โ single cached read instead of 3."""
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### ๐ Dataset Preview")
try:
mtime = os.path.getmtime(csv_path)
head_df, row_count, leads_found, class_dist = _load_preview_data(csv_path, mtime)
st.dataframe(head_df, use_container_width=True)
st.markdown(
f"**Dataset Summary:**\n"
f"- **Total Rows**: `{row_count:,}` samples\n"
f"- **Available Leads**: `{leads_found}`"
)
if class_dist is not None:
st.markdown(f"- **Class Distribution**: `{class_dist}`")
except Exception as e:
st.error(f"Error parsing CSV file: {e}")
st.markdown('</div>', unsafe_allow_html=True)
def _render_controls(config, classification_runner, csv_to_use, dep_ok):
"""Render task selection and run button in left panel."""
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### โ๏ธ Task & Model Configuration")
task_options = list(config.CLASSIFICATION_TASKS.keys())
selected_task = st.selectbox(
"Select Diagnostic Classification Task",
options=task_options,
index=0
)
task_meta = config.CLASSIFICATION_TASKS[selected_task]
st.markdown(
f"<p style='color: #64748B; font-size: 0.85rem; font-style: italic; "
f"margin-top: -8px;'>{task_meta['description']}</p>",
unsafe_allow_html=True
)
# Load pre-trained model metadata to display stats (now cached by the runner)
try:
_, metadata = classification_runner.load_pretrained_model_and_metadata(task_meta['model_dir'])
st.success(f"๐ฏ **Pre-trained model found!**")
st.markdown(
f"- **Model Class**: `{metadata['model_name']}`\n"
f"- **Expected Input**: `{metadata['n_features']} leads x {metadata['n_timesteps']} timesteps`\n"
f"- **Held-out Test Accuracy**: `{metadata['test_metrics']['accuracy']:.2%}`\n"
f"- **Training Date**: `{metadata['training_date']}`"
)
except Exception as e:
st.error(f"Could not load pre-trained model metadata: {e}")
run_class_btn = st.button(
"๐ฏ Run Diagnostic Classification",
use_container_width=True, disabled=not dep_ok
)
st.markdown('</div>', unsafe_allow_html=True)
return run_class_btn, selected_task, task_meta
def _run_classification(config, classification_runner, csv_path, task_meta, selected_task, status_placeholder):
"""Execute the classification pipeline, rendering logs inside status_placeholder."""
with status_placeholder.container():
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### โ๏ธ Running Classification Pipeline...")
progress_bar = st.progress(0)
status_text = st.empty()
st.markdown('</div>', unsafe_allow_html=True)
logs = []
try:
status_text.markdown("๐ **Phase 1/3**: Loading pre-trained model weights...")
progress_bar.progress(20)
model, metadata = classification_runner.load_pretrained_model_and_metadata(task_meta['model_dir'])
logs.append(f"๐ฏ **Model Weights Loaded**: Loaded pre-trained `{metadata['model_name']}` for use-case `{metadata['use_case']}`.")
status_text.markdown("๐ **Phase 2/3**: Segmenting signals into heartbeats around R-peaks (Pan-Tompkins)...")
progress_bar.progress(50)
temp_segmented_csv = tempfile.NamedTemporaryFile(delete=False, suffix=".csv").name
segmented_df = classification_runner.segment_uploaded_csv(
csv_path, temp_segmented_csv, target_fs=config.TARGET_FS
)
n_beats = segmented_df.index.get_level_values('subject_id').nunique() if not segmented_df.empty else 0
logs.append(f"โก **Heartbeat Segmentation**: Segmented raw continuous signal into `{n_beats}` individual heartbeats around detected R-peaks (Pan-Tompkins).")
status_text.markdown("๐ **Phase 3/3**: Normalizing lead voltages & executing model inference...")
progress_bar.progress(80)
X, valid_ids, y_true = classification_runner.preprocess_dataframe_for_inference(segmented_df, metadata)
logs.append(f"๐ **Data Preprocessing**: Formatted data into Numpy3D tensor shape `{X.shape}` and applied max-absolute voltage normalization.")
results = classification_runner.run_pretrained_inference(model, X, metadata, valid_ids)
logs.append(f"๐ฎ **Inference Engine**: Evaluated model predictions and computed confidence levels for `{len(valid_ids)}` inputs.")
# Check if valid ground-truth labels are present
model_labels = [str(x).strip().lower() for x in metadata['class_labels']]
has_ground_truth = len(y_true) > 0 and any(str(lbl).strip().lower() in model_labels for lbl in y_true)
if has_ground_truth:
results['metrics'] = classification_runner.calculate_evaluation_metrics(
y_true, results['predicted_class'], metadata['class_labels'], metadata['positive_class']
)
logs.append("๐ **Evaluation Metrics**: Calculated performance metrics (Accuracy, F1, Sensitivity, Specificity) using available ground-truth labels.")
else:
results['metrics'] = None
progress_bar.progress(100)
status_text.success("โ
**Classification Pipeline Completed!**")
st.session_state["clf_results"] = results
st.session_state["clf_logs"] = logs
st.session_state["selected_task"] = selected_task
# Clear progress bar card since finished results are rendering
status_placeholder.empty()
except classification_runner.DependencyMissingError as e:
status_text.error(str(e))
except Exception as e:
status_text.error(f"โ Classification Pipeline Failed: {str(e)}")
st.exception(e)
@st.cache_data(show_spinner=False)
def _build_confusion_matrix_figure(cm_list, class_labels_mapped):
"""Build and cache the confusion matrix matplotlib figure."""
fig_cm, ax_cm = plt.subplots(figsize=(4, 4))
cm_arr = np.array(cm_list)
ax_cm.imshow(cm_arr, interpolation='nearest', cmap=plt.cm.Reds)
ax_cm.set_xticks(range(len(class_labels_mapped)))
ax_cm.set_yticks(range(len(class_labels_mapped)))
ax_cm.set_xticklabels(class_labels_mapped, fontsize=8)
ax_cm.set_yticklabels(class_labels_mapped, fontsize=8)
ax_cm.set_xlabel("Predicted Label", fontsize=9)
ax_cm.set_ylabel("True Label", fontsize=9)
for r in range(cm_arr.shape[0]):
for c in range(cm_arr.shape[1]):
ax_cm.text(c, r, f"{cm_arr[r, c]}",
ha="center", va="center",
color="white" if cm_arr[r, c] > (cm_arr.max() / 2) else "black",
fontweight="bold")
fig_cm.patch.set_facecolor('white')
ax_cm.set_facecolor('white')
plt.tight_layout()
return fig_cm
def _show_results(task_meta, selected_task):
"""Display execution logs, performance metrics, confusion matrix, and predictions table."""
results = st.session_state["clf_results"]
logs = st.session_state.get("clf_logs", [])
metrics = results.get("metrics")
# 1. Pipeline Execution Status Logs
if logs:
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### โ๏ธ Pipeline Execution Logs")
for log in logs:
st.markdown(f"- {log}")
st.markdown('</div>', unsafe_allow_html=True)
# 2. Performance Metrics
if metrics is not None:
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### ๐ Diagnostic Classification Performance Metrics")
st.markdown(f"""
<div class="metric-container">
<div class="metric-card">
<div class="metric-label">Accuracy</div>
<div class="metric-value">{metrics['Accuracy']:.1%}</div>
</div>
<div class="metric-card">
<div class="metric-label">F1-Score</div>
<div class="metric-value">{metrics['F1']:.1%}</div>
</div>
<div class="metric-card">
<div class="metric-label">Sensitivity</div>
<div class="metric-value">{metrics['Sensitivity']:.1%}</div>
</div>
<div class="metric-card">
<div class="metric-label">Specificity</div>
<div class="metric-value">{metrics['Specificity']:.1%}</div>
</div>
</div>
""", unsafe_allow_html=True)
col_cm1, col_cm2 = st.columns([1, 1])
with col_cm1:
st.markdown("**Confusion Matrix Counts:**")
st.markdown(
f"- **True Negatives (TN)**: `{metrics['TN']}`\n"
f"- **False Positives (FP)**: `{metrics['FP']}`\n"
f"- **False Negatives (FN)**: `{metrics['FN']}`\n"
f"- **True Positives (TP)**: `{metrics['TP']}`"
)
with col_cm2:
class_labels_mapped = list(task_meta["labels"].values())
fig_cm = _build_confusion_matrix_figure(
metrics["Confusion Matrix"], class_labels_mapped
)
st.pyplot(fig_cm)
st.markdown('</div>', unsafe_allow_html=True)
else:
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### ๐ Diagnostic Classification Predictions")
st.info("๐ก **Inference Mode:** The uploaded dataset does not contain ground-truth class labels. Diagnosis outputs for each subject/heartbeat are listed below.")
st.markdown('</div>', unsafe_allow_html=True)
# 3. Predictions Table
st.markdown('<div class="glass-card">', unsafe_allow_html=True)
st.markdown("### ๐ Heartbeat Classification Predictions")
df_preds = pd.DataFrame({
"Subject/Heartbeat ID": results['subject_id'],
"Predicted Diagnosis": results['predicted_class']
})
if 'confidence' in results:
df_preds["Model Confidence"] = [f"{c:.1%}" for c in results['confidence']]
st.dataframe(df_preds, use_container_width=True)
# Download predictions CSV
preds_csv = df_preds.to_csv(index=False).encode('utf-8')
st.download_button(
label="๐ฅ Download Predictions Table (CSV)",
data=preds_csv,
file_name=f"{selected_task.replace(' ', '_').lower()}_predictions.csv",
mime="text/csv",
use_container_width=True
)
st.markdown('</div>', unsafe_allow_html=True)
def _show_idle_guide():
"""Display the instructions/guide in the right panel when idle."""
st.markdown("""
<div class="glass-card">
<h3 style="background: linear-gradient(135deg, #E63946, #0D9488); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin-bottom: 12px;">โค๏ธ Cardiac Diagnosis Workstation</h3>
<p style="font-size: 0.92rem; line-height: 1.7; color: #475569;">
This module applies pre-trained machine learning and deep learning models to classify cardiac conditions from digitized ECG voltage waveforms.
</p>
<p style="font-size: 0.92rem; line-height: 1.7; color: #475569;">
<strong>Standard Workstation Steps:</strong>
</p>
<ul style="font-size: 0.9rem; color: #64748B; margin-left: 20px; margin-top: 10px; line-height: 1.8;">
<li>Select a diagnostic task (e.g. OMI vs non-OMI) in the configuration panel on the left.</li>
<li>Click <strong>๐ฏ Run Diagnostic Classification</strong> to initiate the pipeline.</li>
<li>The system will segment continuous signals into separate heartbeats around R-peaks.</li>
<li>Inference will run instantly using pre-trained weights.</li>
</ul>
<p style="font-size: 0.85rem; color: #94A3B8; margin-top: 15px; font-style: italic;">
Upload a CSV in the configuration panel on the left to begin.
</p>
</div>
""", unsafe_allow_html=True)
|