| |
| """ |
| 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.""" |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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 |
|
|
| |
| col1, col2 = st.columns([2, 3]) |
|
|
| with col1: |
| |
| csv_to_use = _render_upload(config) |
| |
| |
| run_class_btn, selected_task, task_meta = _render_controls(config, classification_runner, csv_to_use, dep_ok) |
|
|
| with col2: |
| |
| 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) |
|
|
| |
| 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_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: |
| |
| 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: |
| |
| 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 |
| ) |
|
|
| |
| 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.") |
|
|
| |
| 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 |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| |
| 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) |
|
|