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)