changeset 10:b0d893d04d4c draft default tip

planemo upload for repository https://github.com/goeckslab/gleam.git commit 1594d503179f28987720594eb49b48a15486f073
author goeckslab
date Mon, 08 Sep 2025 22:38:35 +0000
parents 9e912fce264c
children
files image_learner_cli.py utils.py
diffstat 2 files changed, 433 insertions(+), 348 deletions(-) [+]
line wrap: on
line diff
--- a/image_learner_cli.py	Wed Aug 27 21:02:48 2025 +0000
+++ b/image_learner_cli.py	Mon Sep 08 22:38:35 2025 +0000
@@ -69,7 +69,6 @@
     ]
 
     rows = []
-
     for key in display_keys:
         val = config.get(key, None)
         if key == "threshold":
@@ -136,15 +135,15 @@
                         val_str = val
             else:
                 val_str = val if val is not None else "N/A"
-            if val_str == "N/A" and key not in [
-                "task_type"
-            ]:  # Skip if N/A for non-essential
+            if val_str == "N/A" and key not in ["task_type"]:
                 continue
         rows.append(
             f"<tr>"
-            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>"
+            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>"
             f"{key.replace('_', ' ').title()}</td>"
-            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>"
+            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>"
             f"{val_str}</td>"
             f"</tr>"
         )
@@ -153,13 +152,17 @@
         types = [str(a.get("type", "")) for a in aug_cfg]
         aug_val = ", ".join(types)
         rows.append(
-            f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>Augmentation</td>"
-            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>{aug_val}</td></tr>"
+            f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>Augmentation</td>"
+            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>{aug_val}</td></tr>"
         )
     if split_info:
         rows.append(
-            f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>Data Split</td>"
-            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>{split_info}</td></tr>"
+            f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>Data Split</td>"
+            f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; "
+            f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>{split_info}</td></tr>"
         )
     html = f"""
         <h2 style="text-align: center;">Model and Training Summary</h2>
@@ -946,6 +949,66 @@
         test_viz_dir = base_viz_dir / "test"
 
         html = get_html_template()
+
+        # Extra CSS & JS: center Plotly and enable CSV download for predictions table
+        html += """
+<style>
+  /* Center Plotly figures (both wrapper and native classes) */
+  .plotly-center { display: flex; justify-content: center; }
+  .plotly-center .plotly-graph-div, .plotly-center .js-plotly-plot { margin: 0 auto !important; }
+  .js-plotly-plot, .plotly-graph-div { margin-left: auto !important; margin-right: auto !important; }
+
+  /* Download button for predictions table */
+  .download-btn {
+    padding: 8px 12px;
+    border: 1px solid #4CAF50;
+    background: #4CAF50;
+    color: white;
+    border-radius: 6px;
+    cursor: pointer;
+  }
+  .download-btn:hover { filter: brightness(0.95); }
+  .preds-controls {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+    margin: 8px 0;
+  }
+</style>
+<script>
+  function tableToCSV(table){
+    const rows = Array.from(table.querySelectorAll('tr'));
+    return rows.map(row =>
+      Array.from(row.querySelectorAll('th,td')).map(cell => {
+        let text = cell.innerText.replace(/\\r?\\n|\\r/g,' ').trim();
+        if (text.includes('"') || text.includes(',')) {
+          text = '"' + text.replace(/"/g,'""') + '"';
+        }
+        return text;
+      }).join(',')
+    ).join('\\n');
+  }
+  document.addEventListener('DOMContentLoaded', function(){
+    const btn = document.getElementById('downloadPredsCsv');
+    if(btn){
+      btn.addEventListener('click', function(){
+        const tbl = document.querySelector('.predictions-table');
+        if(!tbl){ alert('Predictions table not found.'); return; }
+        const csv = tableToCSV(tbl);
+        const blob = new Blob([csv], {type: 'text/csv;charset=utf-8;'});
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = 'ground_truth_vs_predictions.csv';
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
+      });
+    }
+  });
+</script>
+"""
         html += f"<h1>{title}</h1>"
 
         metrics_html = ""
@@ -983,31 +1046,38 @@
         except Exception as e:
             logger.warning(f"Could not load config for HTML report: {e}")
 
+        # ---------- image rendering with exclusions ----------
         def render_img_section(
-            title: str, dir_path: Path, output_type: str = None
+            title: str,
+            dir_path: Path,
+            output_type: str = None,
+            exclude_names: Optional[set] = None,
         ) -> str:
             if not dir_path.exists():
                 return f"<h2>{title}</h2><p><em>Directory not found.</em></p>"
-            # collect every PNG
+
+            exclude_names = exclude_names or set()
+
             imgs = list(dir_path.glob("*.png"))
-            # --- EXCLUDE Ludwig's base confusion matrix and any top-N confusion_matrix files ---
+
+            default_exclude = {"confusion_matrix.png", "roc_curves.png"}
+
             imgs = [
                 img
                 for img in imgs
-                if not (
-                    img.name == "confusion_matrix.png"
-                    or img.name.startswith("confusion_matrix__label_top")
-                    or img.name == "roc_curves.png"
-                )
+                if img.name not in default_exclude
+                and img.name not in exclude_names
+                and not img.name.startswith("confusion_matrix__label_top")
             ]
+
             if not imgs:
                 return f"<h2>{title}</h2><p><em>No plots found.</em></p>"
+
             if output_type == "binary":
                 order = [
                     "roc_curves_from_prediction_statistics.png",
                     "compare_performance_label.png",
                     "confusion_matrix_entropy__label_top2.png",
-                    # ...you can tweak ordering as needed
                 ]
                 img_names = {img.name: img for img in imgs}
                 ordered = [img_names[n] for n in order if n in img_names]
@@ -1019,14 +1089,13 @@
                     "compare_classifiers_multiclass_multimetric__label_top10.png",
                     "compare_classifiers_multiclass_multimetric__label_worst10.png",
                 }
+                valid_imgs = [img for img in imgs if img.name not in unwanted]
                 display_order = [
                     "roc_curves.png",
                     "compare_performance_label.png",
                     "compare_classifiers_performance_from_prob.png",
                     "confusion_matrix_entropy__label_top10.png",
                 ]
-                # filter and order
-                valid_imgs = [img for img in imgs if img.name not in unwanted]
                 img_map = {img.name: img for img in valid_imgs}
                 ordered = [img_map[n] for n in display_order if n in img_map]
                 others = sorted(
@@ -1034,27 +1103,36 @@
                 )
                 imgs = ordered + others
             else:
-                # regression: just sort whatever's left
                 imgs = sorted(imgs)
-            # render each remaining PNG
-            html = ""
+
+            html_section = ""
             for img in imgs:
                 b64 = encode_image_to_base64(str(img))
                 img_title = img.stem.replace("_", " ").title()
-                html += (
+                html_section += (
                     f"<h2 style='text-align: center;'>{img_title}</h2>"
                     f'<div class="plot" style="margin-bottom:20px;text-align:center;">'
                     f'<img src="data:image/png;base64,{b64}" '
                     f'style="max-width:90%;max-height:600px;border:1px solid #ddd;" />'
                     f"</div>"
                 )
-            return html
+            return html_section
 
         tab1_content = config_html + metrics_html
+
         tab2_content = train_val_metrics_html + render_img_section(
-            "Training and Validation Visualizations", train_viz_dir
+            "Training and Validation Visualizations",
+            train_viz_dir,
+            output_type,
+            exclude_names={
+                "compare_classifiers_performance_from_prob.png",
+                "roc_curves_from_prediction_statistics.png",
+                "precision_recall_curves_from_prediction_statistics.png",
+                "precision_recall_curve.png",
+            },
         )
-        # --- Predictions vs Ground Truth table ---
+
+        # --- Predictions vs Ground Truth table (REGRESSION ONLY) ---
         preds_section = ""
         parquet_path = exp_dir / PREDICTIONS_PARQUET_FILE_NAME
         if output_type == "regression" and parquet_path.exists():
@@ -1081,13 +1159,19 @@
                 preds_html = df_table.to_html(index=False, classes="predictions-table")
                 preds_section = (
                     "<h2 style='text-align: center;'>Ground Truth vs. Predictions</h2>"
-                    "<div style='overflow-y:auto; max-height:400px; overflow-x:auto; margin-bottom:20px;'>"
+                    "<div class='preds-controls'>"
+                    "<button id='downloadPredsCsv' class='download-btn'>Download CSV</button>"
+                    "</div>"
+                    "<div class='scroll-rows-30' style='overflow-x:auto; overflow-y:auto; max-height:900px; margin-bottom:20px;'>"
                     + preds_html
                     + "</div>"
                 )
             except Exception as e:
                 logger.warning(f"Could not build Predictions vs GT table: {e}")
+
         tab3_content = test_metrics_html + preds_section
+
+        # Classification-only interactive Plotly panels (centered)
         if output_type in ("binary", "category"):
             training_stats_path = exp_dir / "training_statistics.json"
             interactive_plots = build_classification_plots(
@@ -1095,31 +1179,16 @@
                 str(training_stats_path),
             )
             for plot in interactive_plots:
-                # 2) inject the static "roc_curves_from_prediction_statistics.png"
-                if plot["title"] == "ROC-AUC":
-                    static_img = (
-                        test_viz_dir / "roc_curves_from_prediction_statistics.png"
-                    )
-                    if static_img.exists():
-                        b64 = encode_image_to_base64(str(static_img))
-                        tab3_content += (
-                            "<h2 style='text-align: center;'>"
-                            "Roc Curves From Prediction Statistics"
-                            "</h2>"
-                            f'<div class="plot" style="margin-bottom:20px;text-align:center;">'
-                            f'<img src="data:image/png;base64,{b64}" '
-                            f'style="max-width:90%;max-height:600px;border:1px solid #ddd;" />'
-                            "</div>"
-                        )
-                # always render the plotly panels exactly as before
                 tab3_content += (
                     f"<h2 style='text-align: center;'>{plot['title']}</h2>"
-                    + plot["html"]
+                    f"<div class='plotly-center'>{plot['html']}</div>"
                 )
-            tab3_content += render_img_section(
-                "Test Visualizations", test_viz_dir, output_type
-            )
-        # assemble the tabs and help modal
+
+        # Add static TEST PNGs (with default dedupe/exclusions)
+        tab3_content += render_img_section(
+            "Test Visualizations", test_viz_dir, output_type
+        )
+
         tabbed_html = build_tabbed_html(tab1_content, tab2_content, tab3_content)
         modal_html = get_metrics_help_modal()
         html += tabbed_html + modal_html + get_html_closing()
--- a/utils.py	Wed Aug 27 21:02:48 2025 +0000
+++ b/utils.py	Mon Sep 08 22:38:35 2025 +0000
@@ -3,205 +3,301 @@
 
 
 def get_html_template():
+    """
+    Returns the opening HTML, <head> (with CSS/JS), and opens <body> + .container.
+    Includes:
+      - Base styling for layout and tables
+      - Sortable table headers with 3-state arrows (none ⇅, asc ↑, desc ↓)
+      - A scroll helper class (.scroll-rows-30) that approximates ~30 visible rows
+      - A guarded script so initializing runs only once even if injected twice
+    """
     return """
-    <html>
-    <head>
-        <meta charset="UTF-8">
-        <title>Galaxy-Ludwig Report</title>
-        <style>
-          body {
-              font-family: Arial, sans-serif;
-              margin: 0;
-              padding: 20px;
-              background-color: #f4f4f4;
-          }
-          .container {
-              max-width: 800px;
-              margin: auto;
-              background: white;
-              padding: 20px;
-              box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
-              overflow-x: auto;
-          }
-          h1 {
-              text-align: center;
-              color: #333;
-          }
-          h2 {
-              border-bottom: 2px solid #4CAF50;
-              color: #4CAF50;
-              padding-bottom: 5px;
-          }
-          /* baseline table setup */
-          table {
-              border-collapse: collapse;
-              margin: 20px 0;
-              width: 100%;
-              table-layout: fixed;
-          }
-          table, th, td {
-              border: 1px solid #ddd;
-          }
-          th, td {
-              padding: 8px;
-              text-align: center;
-              vertical-align: middle;
-              word-wrap: break-word;
-          }
-          th {
-              background-color: #4CAF50;
-              color: white;
-          }
-          .plot {
-              text-align: center;
-              margin: 20px 0;
-          }
-          .plot img {
-              max-width: 100%;
-              height: auto;
-          }
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>Galaxy-Ludwig Report</title>
+  <style>
+    body {
+      font-family: Arial, sans-serif;
+      margin: 0;
+      padding: 20px;
+      background-color: #f4f4f4;
+    }
+    .container {
+      max-width: 1200px;
+      margin: auto;
+      background: white;
+      padding: 20px;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+      overflow-x: auto;
+    }
+    h1 {
+      text-align: center;
+      color: #333;
+    }
+    h2 {
+      border-bottom: 2px solid #4CAF50;
+      color: #4CAF50;
+      padding-bottom: 5px;
+      margin-top: 28px;
+    }
+
+    /* baseline table setup */
+    table {
+      border-collapse: collapse;
+      margin: 20px 0;
+      width: 100%;
+      table-layout: fixed;
+      background: #fff;
+    }
+    table, th, td {
+      border: 1px solid #ddd;
+    }
+    th, td {
+      padding: 10px;
+      text-align: center;
+      vertical-align: middle;
+      word-break: break-word;
+      white-space: normal;
+      overflow-wrap: anywhere;
+    }
+    th {
+      background-color: #4CAF50;
+      color: white;
+    }
+
+    .plot {
+      text-align: center;
+      margin: 20px 0;
+    }
+    .plot img {
+      max-width: 100%;
+      height: auto;
+      border: 1px solid #ddd;
+    }
+
+    /* -------------------
+       sortable columns (3-state: none ⇅, asc ↑, desc ↓)
+       ------------------- */
+    table.performance-summary th.sortable {
+      cursor: pointer;
+      position: relative;
+      user-select: none;
+    }
+    /* default icon space */
+    table.performance-summary th.sortable::after {
+      content: '⇅';
+      position: absolute;
+      right: 12px;
+      top: 50%;
+      transform: translateY(-50%);
+      font-size: 0.8em;
+      color: #eaf5ea; /* light on green */
+      text-shadow: 0 0 1px rgba(0,0,0,0.15);
+    }
+    /* three states override the default */
+    table.performance-summary th.sortable.sorted-none::after { content: '⇅'; color: #eaf5ea; }
+    table.performance-summary th.sortable.sorted-asc::after  { content: '↑';  color: #ffffff; }
+    table.performance-summary th.sortable.sorted-desc::after { content: '↓';  color: #ffffff; }
+
+    /* show ~30 rows with a scrollbar (tweak if you want) */
+    .scroll-rows-30 {
+      max-height: 900px;       /* ~30 rows depending on row height */
+      overflow-y: auto;        /* vertical scrollbar (“sidebar”) */
+      overflow-x: auto;
+    }
 
-          /* -------------------
-             SORTABLE COLUMNS
-             ------------------- */
-          table.performance-summary th.sortable {
-            cursor: pointer;
-            position: relative;
-            user-select: none;
-          }
-          /* hide arrows by default */
-          table.performance-summary th.sortable::after {
-            content: '';
-            position: absolute;
-            right: 12px;
-            top: 50%;
-            transform: translateY(-50%);
-            font-size: 0.8em;
-            color: #666;
-          }
-          /* three states */
-          table.performance-summary th.sortable.sorted-none::after {
-            content: '⇅';
-          }
-          table.performance-summary th.sortable.sorted-asc::after {
-            content: '↑';
-          }
-          table.performance-summary th.sortable.sorted-desc::after {
-            content: '↓';
-          }
-        </style>
+    /* Tabs + Help button (used by build_tabbed_html) */
+    .tabs {
+      display: flex;
+      align-items: center;
+      border-bottom: 2px solid #ccc;
+      margin-bottom: 1rem;
+      gap: 6px;
+      flex-wrap: wrap;
+    }
+    .tab {
+      padding: 10px 20px;
+      cursor: pointer;
+      border: 1px solid #ccc;
+      border-bottom: none;
+      background: #f9f9f9;
+      margin-right: 5px;
+      border-top-left-radius: 8px;
+      border-top-right-radius: 8px;
+    }
+    .tab.active {
+      background: white;
+      font-weight: bold;
+    }
+    .help-btn {
+      margin-left: auto;
+      padding: 6px 12px;
+      font-size: 0.9rem;
+      border: 1px solid #4CAF50;
+      border-radius: 4px;
+      background: #4CAF50;
+      color: white;
+      cursor: pointer;
+    }
+    .tab-content {
+      display: none;
+      padding: 20px;
+      border: 1px solid #ccc;
+      border-top: none;
+      background: #fff;
+    }
+    .tab-content.active {
+      display: block;
+    }
 
-        <!-- sorting script -->
-        <script>
-        document.addEventListener('DOMContentLoaded', () => {
-          // 1) record each row's original position
-          document.querySelectorAll('table.performance-summary tbody').forEach(tbody => {
-            Array.from(tbody.rows).forEach((row, i) => {
-              row.dataset.originalOrder = i;
-            });
-          });
+    /* Modal (used by get_metrics_help_modal) */
+    .modal {
+      display: none;
+      position: fixed;
+      z-index: 9999;
+      left: 0; top: 0;
+      width: 100%; height: 100%;
+      overflow: auto;
+      background-color: rgba(0,0,0,0.4);
+    }
+    .modal-content {
+      background-color: #fefefe;
+      margin: 8% auto;
+      padding: 20px;
+      border: 1px solid #888;
+      width: 90%;
+      max-width: 900px;
+      border-radius: 8px;
+    }
+    .modal .close {
+      color: #777;
+      float: right;
+      font-size: 28px;
+      font-weight: bold;
+      line-height: 1;
+      margin-left: 8px;
+    }
+    .modal .close:hover,
+    .modal .close:focus {
+      color: black;
+      text-decoration: none;
+      cursor: pointer;
+    }
+    .metrics-guide h3 { margin-top: 20px; }
+    .metrics-guide p { margin: 6px 0; }
+    .metrics-guide ul { margin: 10px 0; padding-left: 20px; }
+  </style>
 
-          const getText = cell => cell.innerText.trim();
-          const comparer = (idx, asc) => (a, b) => {
-            const v1 = getText(a.children[idx]);
-            const v2 = getText(b.children[idx]);
-            const n1 = parseFloat(v1), n2 = parseFloat(v2);
-            if (!isNaN(n1) && !isNaN(n2)) {
-              return asc ? n1 - n2 : n2 - n1;
+  <script>
+    // Guard to avoid double-initialization if this block is included twice
+    (function(){
+      if (window.__perfSummarySortInit) return;
+      window.__perfSummarySortInit = true;
+
+      function initPerfSummarySorting() {
+        // Record original order for "back to original"
+        document.querySelectorAll('table.performance-summary tbody').forEach(tbody => {
+          Array.from(tbody.rows).forEach((row, i) => { row.dataset.originalOrder = i; });
+        });
+
+        const getText = td => (td?.innerText || '').trim();
+        const cmp = (idx, asc) => (a, b) => {
+          const v1 = getText(a.children[idx]);
+          const v2 = getText(b.children[idx]);
+          const n1 = parseFloat(v1), n2 = parseFloat(v2);
+          if (!isNaN(n1) && !isNaN(n2)) return asc ? n1 - n2 : n2 - n1; // numeric
+          return asc ? v1.localeCompare(v2) : v2.localeCompare(v1);       // lexical
+        };
+
+        document.querySelectorAll('table.performance-summary th.sortable').forEach(th => {
+          // initialize to “none”
+          th.classList.remove('sorted-asc','sorted-desc');
+          th.classList.add('sorted-none');
+
+          th.addEventListener('click', () => {
+            const table = th.closest('table');
+            const headerRow = th.parentNode;
+            const allTh = headerRow.querySelectorAll('th.sortable');
+            const tbody = table.querySelector('tbody');
+
+            // Determine current state BEFORE clearing
+            const isAsc  = th.classList.contains('sorted-asc');
+            const isDesc = th.classList.contains('sorted-desc');
+
+            // Reset all headers in this row
+            allTh.forEach(x => x.classList.remove('sorted-asc','sorted-desc','sorted-none'));
+
+            // Compute next state
+            let next;
+            if (!isAsc && !isDesc) {
+              next = 'asc';
+            } else if (isAsc) {
+              next = 'desc';
+            } else {
+              next = 'none';
             }
-            return asc
-              ? v1.localeCompare(v2)
-              : v2.localeCompare(v1);
-          };
-
-          document
-            .querySelectorAll('table.performance-summary th.sortable')
-            .forEach(th => {
-              // initialize to "none" state
-              th.classList.add('sorted-none');
-              th.addEventListener('click', () => {
-                const table = th.closest('table');
-                const allTh = table.querySelectorAll('th.sortable');
-
-                // 1) determine current state BEFORE clearing classes
-                let curr = th.classList.contains('sorted-asc')
-                  ? 'asc'
-                  : th.classList.contains('sorted-desc')
-                    ? 'desc'
-                    : 'none';
-                // 2) cycle to next state
-                let next = curr === 'none'
-                  ? 'asc'
-                  : curr === 'asc'
-                    ? 'desc'
-                    : 'none';
+            th.classList.add('sorted-' + next);
 
-                // 3) clear all sort markers
-                allTh.forEach(h =>
-                  h.classList.remove('sorted-none','sorted-asc','sorted-desc')
-                );
-                // 4) apply the new marker
-                th.classList.add(`sorted-${next}`);
+            // Sort rows according to the chosen state
+            const rows = Array.from(tbody.rows);
+            if (next === 'none') {
+              rows.sort((a, b) => (a.dataset.originalOrder - b.dataset.originalOrder));
+            } else {
+              const idx = Array.from(headerRow.children).indexOf(th);
+              rows.sort(cmp(idx, next === 'asc'));
+            }
+            rows.forEach(r => tbody.appendChild(r));
+          });
+        });
+      }
 
-                // 5) sort or restore original order
-                const tbody = table.querySelector('tbody');
-                let rows = Array.from(tbody.rows);
-                if (next === 'none') {
-                  rows.sort((a, b) =>
-                    a.dataset.originalOrder - b.dataset.originalOrder
-                  );
-                } else {
-                  const idx = Array.from(th.parentNode.children).indexOf(th);
-                  rows.sort(comparer(idx, next === 'asc'));
-                }
-                rows.forEach(r => tbody.appendChild(r));
-              });
-            });
-        });
-        </script>
-    </head>
-    <body>
-    <div class="container">
-    """
+      // Run after DOM is ready
+      if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', initPerfSummarySorting);
+      } else {
+        initPerfSummarySorting();
+      }
+    })();
+  </script>
+</head>
+<body>
+  <div class="container">
+"""
 
 
 def get_html_closing():
+    """Closes .container, body, and html."""
     return """
-    </div>
-    </body>
-    </html>
-    """
+  </div>
+</body>
+</html>
+"""
 
 
-def encode_image_to_base64(image_path):
+def encode_image_to_base64(image_path: str) -> str:
     """Convert an image file to a base64 encoded string."""
     with open(image_path, "rb") as img_file:
         return base64.b64encode(img_file.read()).decode("utf-8")
 
 
-def json_to_nested_html_table(json_data, depth=0):
+def json_to_nested_html_table(json_data, depth: int = 0) -> str:
     """
-    Convert JSON object to an HTML nested table.
-
-    Parameters:
-        json_data (dict or list): The JSON data to convert.
-        depth (int): Current depth level for indentation.
-
-    Returns:
-        str: HTML string for the nested table.
+    Convert a JSON-able object to an HTML nested table.
+    Renders dicts as two-column tables (key/value) and lists as index/value rows.
     """
-    # Base case: if JSON is a simple key-value pair dictionary
+    # Base case: flat dict (no nested dict/list values)
     if isinstance(json_data, dict) and all(
         not isinstance(v, (dict, list)) for v in json_data.values()
     ):
-        # Render a flat table
         rows = [
             f"<tr><th>{key}</th><td>{value}</td></tr>"
             for key, value in json_data.items()
         ]
         return f"<table>{''.join(rows)}</table>"
 
-    # Base case: if JSON is a list of simple values
+    # Base case: list of simple values
     if isinstance(json_data, list) and all(
         not isinstance(v, (dict, list)) for v in json_data
     ):
@@ -211,36 +307,34 @@
         ]
         return f"<table>{''.join(rows)}</table>"
 
-    # Recursive case: if JSON contains nested structures
+    # Recursive cases
     if isinstance(json_data, dict):
         rows = [
-            f"<tr><th style='padding-left:{depth * 20}px;'>{key}</th>"
-            f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
+            (
+                f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>{key}</th>"
+                f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
+            )
             for key, value in json_data.items()
         ]
         return f"<table>{''.join(rows)}</table>"
 
     if isinstance(json_data, list):
         rows = [
-            f"<tr><th style='padding-left:{depth * 20}px;'>[{i}]</th>"
-            f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
+            (
+                f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>[{i}]</th>"
+                f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
+            )
             for i, value in enumerate(json_data)
         ]
         return f"<table>{''.join(rows)}</table>"
 
-    # Base case: simple value
+    # Primitive
     return f"{json_data}"
 
 
-def json_to_html_table(json_data):
+def json_to_html_table(json_data) -> str:
     """
-    Convert JSON to a vertically oriented HTML table.
-
-    Parameters:
-        json_data (str or dict): JSON string or dictionary.
-
-    Returns:
-        str: HTML table representation.
+    Convert JSON (dict or string) into a vertically oriented HTML table.
     """
     if isinstance(json_data, str):
         json_data = json.loads(json_data)
@@ -248,56 +342,19 @@
 
 
 def build_tabbed_html(metrics_html: str, train_val_html: str, test_html: str) -> str:
+    """
+    Build a 3-tab interface:
+      - Config and Results Summary
+      - Train/Validation Results
+      - Test Results
+    Includes a persistent "Help" button that toggles the metrics modal.
+    """
     return f"""
-<style>
-  .tabs {{
-    display: flex;
-    align-items: center;
-    border-bottom: 2px solid #ccc;
-    margin-bottom: 1rem;
-  }}
-  .tab {{
-    padding: 10px 20px;
-    cursor: pointer;
-    border: 1px solid #ccc;
-    border-bottom: none;
-    background: #f9f9f9;
-    margin-right: 5px;
-    border-top-left-radius: 8px;
-    border-top-right-radius: 8px;
-  }}
-  .tab.active {{
-    background: white;
-    font-weight: bold;
-  }}
-  /* new help-button styling */
-  .help-btn {{
-    margin-left: auto;
-    padding: 6px 12px;
-    font-size: 0.9rem;
-    border: 1px solid #4CAF50;
-    border-radius: 4px;
-    background: #4CAF50;
-    color: white;
-    cursor: pointer;
-  }}
-  .tab-content {{
-    display: none;
-    padding: 20px;
-    border: 1px solid #ccc;
-    border-top: none;
-  }}
-  .tab-content.active {{
-    display: block;
-  }}
-</style>
-
 <div class="tabs">
   <div class="tab active" onclick="showTab('metrics')">Config and Results Summary</div>
   <div class="tab" onclick="showTab('trainval')">Train/Validation Results</div>
   <div class="tab" onclick="showTab('test')">Test Results</div>
-  <!-- always-visible help button -->
-  <button id="openMetricsHelp" class="help-btn">Help</button>
+  <button id="openMetricsHelp" class="help-btn" title="Open metrics help">Help</button>
 </div>
 
 <div id="metrics" class="tab-content active">
@@ -311,17 +368,26 @@
 </div>
 
 <script>
-function showTab(id) {{
-  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
-  document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
-  document.getElementById(id).classList.add('active');
-  document.querySelector(`.tab[onclick*="${{id}}"]`).classList.add('active');
-}}
+  function showTab(id) {{
+    document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
+    document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
+    document.getElementById(id).classList.add('active');
+    // find tab with matching onclick target
+    document.querySelectorAll('.tab').forEach(t => {{
+      if (t.getAttribute('onclick') && t.getAttribute('onclick').includes(id)) {{
+        t.classList.add('active');
+      }}
+    }});
+  }}
 </script>
 """
 
 
 def get_metrics_help_modal() -> str:
+    """
+    Returns a ready-to-use modal with a comprehensive metrics guide and
+    the small script that wires the "Help" button to open/close the modal.
+    """
     modal_html = (
         '<div id="metricsHelpModal" class="modal">'
         '  <div class="modal-content">'
@@ -442,73 +508,23 @@
         "  </div>"
         "</div>"
     )
-    modal_css = (
-        "<style>"
-        ".modal {"
-        "  display: none;"
-        "  position: fixed;"
-        "  z-index: 1;"
-        "  left: 0;"
-        "  top: 0;"
-        "  width: 100%;"
-        "  height: 100%;"
-        "  overflow: auto;"
-        "  background-color: rgba(0,0,0,0.4);"
-        "}"
-        ".modal-content {"
-        "  background-color: #fefefe;"
-        "  margin: 15% auto;"
-        "  padding: 20px;"
-        "  border: 1px solid #888;"
-        "  width: 80%;"
-        "  max-width: 800px;"
-        "}"
-        ".close {"
-        "  color: #aaa;"
-        "  float: right;"
-        "  font-size: 28px;"
-        "  font-weight: bold;"
-        "}"
-        ".close:hover,"
-        ".close:focus {"
-        "  color: black;"
-        "  text-decoration: none;"
-        "  cursor: pointer;"
-        "}"
-        ".metrics-guide h3 {"
-        "  margin-top: 20px;"
-        "}"
-        ".metrics-guide p {"
-        "  margin: 5px 0;"
-        "}"
-        ".metrics-guide ul {"
-        "  margin: 10px 0;"
-        "  padding-left: 20px;"
-        "}"
-        "</style>"
-    )
+
     modal_js = (
         "<script>"
-        'document.addEventListener("DOMContentLoaded", function() {'
-        '  var modal = document.getElementById("metricsHelpModal");'
-        '  var openBtn = document.getElementById("openMetricsHelp");'
-        '  var span = document.getElementsByClassName("close")[0];'
+        "document.addEventListener('DOMContentLoaded', function() {"
+        "  var modal = document.getElementById('metricsHelpModal');"
+        "  var openBtn = document.getElementById('openMetricsHelp');"
+        "  var closeBtn = modal ? modal.querySelector('.close') : null;"
         "  if (openBtn && modal) {"
-        "    openBtn.onclick = function() {"
-        '      modal.style.display = "block";'
-        "    };"
+        "    openBtn.addEventListener('click', function(){ modal.style.display = 'block'; });"
         "  }"
-        "  if (span && modal) {"
-        "    span.onclick = function() {"
-        '      modal.style.display = "none";'
-        "    };"
+        "  if (closeBtn && modal) {"
+        "    closeBtn.addEventListener('click', function(){ modal.style.display = 'none'; });"
         "  }"
-        "  window.onclick = function(event) {"
-        "    if (event.target == modal) {"
-        '      modal.style.display = "none";'
-        "    }"
-        "  }"
+        "  window.addEventListener('click', function(ev){"
+        "    if (ev.target === modal) { modal.style.display = 'none'; }"
+        "  });"
         "});"
         "</script>"
     )
-    return modal_css + modal_html + modal_js
+    return modal_html + modal_js