devjas1 commited on
Commit
5076875
Β·
1 Parent(s): 9d0759c

(FEAT): Enhance application with streamlined analysis and confidence visualization

Browse files

- Add streamlined "Details" tab with a research-grade layout for analysis results.
- Implement a unified confidence analysis section with a 2-column layout.
- Improve probability distribution visualization using bullet charts.
- Refactor classification results to include prediction, confidence, and ground truth in a compact format.
- Optimize layout for better readability and reduced visual clutter.
- Ensure compatibility with batch and single-file processing modes.

Files changed (2) hide show
  1. .gitignore +1 -0
  2. app.py +574 -296
.gitignore CHANGED
@@ -15,6 +15,7 @@ outputs/logs/
15
  docs/PROJECT_REPORT.md
16
  wea-*.txt
17
  sta-*.txt
 
18
 
19
  # --- Data (keep folder, ignore files) ---
20
  datasets/**
 
15
  docs/PROJECT_REPORT.md
16
  wea-*.txt
17
  sta-*.txt
18
+ S3PR.md
19
 
20
  # --- Data (keep folder, ignore files) ---
21
  datasets/**
app.py CHANGED
@@ -1,3 +1,9 @@
 
 
 
 
 
 
1
  from models.resnet_cnn import ResNet1D
2
  from models.figure2_cnn import Figure2CNN
3
  import hashlib
@@ -21,155 +27,189 @@ if utils_path.is_dir() and str(utils_path) not in sys.path:
21
  sys.path.append(str(utils_path))
22
  matplotlib.use("Agg") # ensure headless rendering in Spaces
23
 
24
- #==Import local modules + new modules==
25
- from utils.preprocessing import resample_spectrum
26
- from utils.errors import ErrorHandler, safe_execute
27
- from utils.results_manager import ResultsManager
28
- from utils.confidence import calculate_softmax_confidence, get_confidence_badge, create_confidence_progress_html
29
- from utils.multifile import create_batch_uploader, process_multiple_files, display_batch_results
30
 
31
  KEEP_KEYS = {
32
  # ==global UI context we want to keep after "Reset"==
33
  "model_select", # sidebar model key
34
  "input_mode", # radio for Upload|Sample
35
- "uploader_version", # version counter for file uploader
36
  "input_registry", # radio controlling Upload vs Sample
37
  }
38
 
39
- #==Page Configuration==
40
  st.set_page_config(
41
  page_title="ML Polymer Classification",
42
  page_icon="πŸ”¬",
43
  layout="wide",
44
- initial_sidebar_state="expanded"
 
 
45
  )
46
 
47
- #==Custom CSS Page + Element Styling==
48
  st.markdown("""
49
  <style>
50
- /* Keep only scoped utility styles; no .block-container edits */
 
 
 
 
 
 
 
 
 
 
51
 
52
- /* Tabs content area height (your original intent) */
53
- div[data-testid="stTabs"] > div[role="tablist"] + div { min-height: 420px; }
 
 
 
 
 
54
 
55
- /* Compact info box for confidence bar */
56
  .confbox {
57
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
58
- font-size: 0.95rem;
59
- padding: 8px 10px; border: 1px solid rgba(0,0,0,.07);
60
- border-radius: 8px; background: rgba(0,0,0,.02);
 
 
61
  }
62
 
63
- /* Clean key–value rows for technical info */
64
- .kv-row { display:flex; justify-content:space-between;
65
- border-bottom: 1px dotted rgba(0,0,0,.10); padding: 3px 0; gap: 12px; }
66
- .kv-key { opacity:.75; font-size: 0.95rem; white-space: nowrap; }
67
- .kv-val { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
68
- overflow-wrap: anywhere; }
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- /* Ensure markdown h5 headings remain visible after layout shifts */
71
- :where(h5, .stMarkdown h5) { margin-top: 0.25rem; }
 
 
 
 
72
 
73
- /* === Base Expander Header === */
74
  div.stExpander > details > summary {
75
  display: flex;
76
  align-items: center;
77
  justify-content: space-between;
78
- list-style: none; /* remove default arrow */
79
  cursor: pointer;
80
- border: 1px solid rgba(0,0,0,.15);
81
- border-left: 4px solid #9ca3af; /* default gray accent */
82
  border-radius: 6px;
83
- padding: 6px 12px;
84
- margin: 6px 0;
85
- background: rgba(0,0,0,0.04);
86
  font-weight: 600;
87
  font-size: 0.95rem;
 
88
  }
89
 
90
- /* Remove ugly default disclosure triangle */
91
- div.stExpander > details > summary::-webkit-details-marker {
92
- display: none;
93
- }
94
  div.stExpander > details > summary::marker {
95
  display: none;
96
  }
97
 
98
- /* Hover/active subtlety */
99
- div.stExpander > details[open] > summary {
100
- background: rgba(0,0,0,0.06);
101
- }
102
-
103
- /* Hide Streamlit's custom arrow icon inside expanders */
104
  div[data-testid="stExpander"] summary svg {
105
  display: none !important;
106
  }
107
 
108
- /* === Right Badge === */
 
 
 
 
 
109
  div.stExpander > details > summary::after {
110
- content: "MORE ↓";
111
- font-size: 0.70rem;
112
- font-weight: 600;
113
- letter-spacing: .04em;
114
- padding: 2px 8px;
 
115
  border-radius: 999px;
116
- margin-left: auto;
117
- background: #e5e7eb;
118
- color: #111827;
119
  }
120
 
121
- /* === Stable cross-browser expander behavior === */
122
  .expander-marker + div[data-testid="stExpander"] summary {
123
- border-left-color: #2e7d32;
124
- background: rgba(46,125,50,0.08);
125
  }
126
  .expander-marker + div[data-testid="stExpander"] summary::after {
127
  content: "RESULTS";
128
- background: rgba(46,125,50,0.15);
129
- color: #184a1d;
130
  }
131
 
 
 
 
132
 
 
133
  div.stExpander:has(summary:contains("Technical")) > details > summary {
134
- border-left-color: #ed6c02;
135
- background: rgba(237,108,2,0.08);
136
  }
137
  div.stExpander:has(summary:contains("Technical")) > details > summary::after {
138
  content: "ADVANCED";
139
- background: rgba(237,108,2,0.18); color: #7a3d00;
 
140
  }
141
 
142
- /* === FONT SIZE STANDARDIZATION === */
143
-
144
- /* Sidebar metrics (Accuracy, F1 Score) */
145
  div[data-testid="stMetricValue"] {
146
- font-size: 0.95rem !important; /* uniform body size */
 
147
  }
148
  div[data-testid="stMetricLabel"] {
149
  font-size: 0.85rem !important;
150
- opacity: 0.85;
 
151
  }
152
 
153
- /* Sidebar expander text */
154
- section[data-testid="stSidebar"] .stMarkdown p {
155
  font-size: 0.95rem !important;
156
- line-height: 1.4;
 
157
  }
158
 
159
- /* Diagnostics tab metrics (Logits) */
160
  div[data-testid="stMetricValue"] {
161
  font-size: 0.95rem !important;
 
162
  }
163
  div[data-testid="stMetricLabel"] {
164
  font-size: 0.85rem !important;
 
165
  }
166
-
167
-
168
  </style>
169
  """, unsafe_allow_html=True)
170
 
171
 
172
- #==CONSTANTS==
173
  TARGET_LEN = 500
174
  SAMPLE_DATA_DIR = Path("sample_data")
175
  # Prefer env var, else 'model_weights' if present; else canonical 'outputs'
@@ -198,11 +238,11 @@ MODEL_CONFIG = {
198
  }
199
  }
200
 
201
- #==Label mapping==
202
  LABEL_MAP = {0: "Stable (Unweathered)", 1: "Weathered (Degraded)"}
203
 
204
 
205
- #==UTILITY FUNCTIONS==
206
  def init_session_state():
207
  """Keep a persistent session state"""
208
  defaults = {
@@ -219,7 +259,7 @@ def init_session_state():
219
  "uploader_version": 0,
220
  "current_upload_key": "upload_txt_0",
221
  "active_tab": "Details",
222
- "batch_mode": False # Track if in batch mode
223
  }
224
  for k, v in defaults.items():
225
  st.session_state.setdefault(k, v)
@@ -228,7 +268,7 @@ def init_session_state():
228
  if key not in st.session_state:
229
  st.session_state[key] = default_value
230
 
231
- #==Initialize results table==
232
  ResultsManager.init_results_table()
233
 
234
 
@@ -248,7 +288,7 @@ def label_file(filename: str) -> int:
248
  def load_state_dict(_mtime, model_path):
249
  """Load state dict with mtime in cache key to detect file changes"""
250
  try:
251
- return torch.load(model_path, map_location="cpu", weights_only=True)
252
  except (FileNotFoundError, RuntimeError) as e:
253
  st.warning(f"Error loading state dict: {e}")
254
  return None
@@ -286,7 +326,7 @@ def load_model(model_name):
286
  else:
287
  return model, False
288
 
289
- except (FileNotFoundError, KeyError) as e:
290
  st.error(f"❌ Error loading model {model_name}: {str(e)}")
291
  return None, False
292
 
@@ -297,6 +337,7 @@ def cleanup_memory():
297
  if torch.cuda.is_available():
298
  torch.cuda.empty_cache()
299
 
 
300
  @st.cache_data
301
  def run_inference(y_resampled, model_choice, _cache_key=None):
302
  """Run model inference and cache results"""
@@ -304,12 +345,14 @@ def run_inference(y_resampled, model_choice, _cache_key=None):
304
  if not model_loaded:
305
  return None, None, None, None, None
306
 
307
- input_tensor = torch.tensor(y_resampled, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
 
308
  start_time = time.time()
309
  model.eval()
310
  with torch.no_grad():
311
  if model is None:
312
- raise ValueError("Model is not loaded. Please check the model configuration or weights.")
 
313
  logits = model(input_tensor)
314
  prediction = torch.argmax(logits, dim=1).item()
315
  logits_list = logits.detach().numpy().tolist()[0]
@@ -374,6 +417,7 @@ def parse_spectrum_data(raw_text):
374
 
375
  return x, y
376
 
 
377
  @st.cache_data
378
  def create_spectrum_plot(x_raw, y_raw, x_resampled, y_resampled, _cache_key=None):
379
  """Create spectrum visualization plot"""
@@ -388,7 +432,8 @@ def create_spectrum_plot(x_raw, y_raw, x_resampled, y_resampled, _cache_key=None
388
  ax[0].legend()
389
 
390
  # == Resampled spectrum ==
391
- ax[1].plot(x_resampled, y_resampled, label="Resampled", color="steelblue", linewidth=1)
 
392
  ax[1].set_title(f"Resampled ({len(y_resampled)} points)")
393
  ax[1].set_xlabel("Wavenumber (cm⁻¹)")
394
  ax[1].set_ylabel("Intensity")
@@ -404,7 +449,6 @@ def create_spectrum_plot(x_raw, y_raw, x_resampled, y_resampled, _cache_key=None
404
 
405
  return Image.open(buf)
406
 
407
- from typing import Union
408
 
409
  def render_confidence_progress(
410
  probs: np.ndarray,
@@ -412,42 +456,59 @@ def render_confidence_progress(
412
  highlight_idx: Union[int, None] = None,
413
  side_by_side: bool = True
414
  ):
415
- """Render Streamlit native progress bars (0 - 100). Optionally bold the winning class
416
- and place the two bars side-by-side for compactness."""
417
  p = np.asarray(probs, dtype=float)
418
  p = np.clip(p, 0.0, 1.0)
419
 
420
- def _title(i: int, lbl: str, val: float) -> str:
421
- t = f"{lbl} - {val*100:.1f}%"
422
- return f"**{t}**" if (highlight_idx is not None and i == highlight_idx) else t
423
-
424
  if side_by_side:
425
  cols = st.columns(len(labels))
426
  for i, (lbl, val, col) in enumerate(zip(labels, p, cols)):
427
  with col:
428
- st.markdown(_title(i, lbl, float(val)))
 
 
 
429
  st.progress(int(round(val * 100)))
430
  else:
 
431
  for i, (lbl, val) in enumerate(zip(labels, p)):
432
- st.markdown(_title(i, lbl, float(val)))
433
- st.progress(int(round(val * 100)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
 
436
  def render_kv_grid(d: dict, ncols: int = 2):
437
- """Display dict as a clean grid of key/value rows."""
438
- if not d:
439
  return
440
  items = list(d.items())
441
  cols = st.columns(ncols)
442
  for i, (k, v) in enumerate(items):
443
  with cols[i % ncols]:
444
- st.markdown(
445
- f"<div class='kv-row'><span class='kv-key'>{k}</span>"
446
- f"<span class='kv-val'>{v}</span></div>",
447
- unsafe_allow_html=True
448
- )
449
-
450
-
451
 
452
 
453
  def render_model_meta(model_choice: str):
@@ -478,14 +539,17 @@ def get_confidence_description(logit_margin):
478
  else:
479
  return "LOW", "πŸ”΄"
480
 
 
481
  def log_message(msg: str):
482
  """Append a timestamped line to the in-app log, creating the buffer if needed."""
483
  ErrorHandler.log_info(msg)
484
 
 
485
  def trigger_run():
486
  """Set a flag so we can detect button press reliably across reruns"""
487
  st.session_state['run_requested'] = True
488
 
 
489
  def on_sample_change():
490
  """Read selected sample once and persist as text."""
491
  sel = st.session_state.get("sample_select", "-- Select Sample --")
@@ -504,23 +568,32 @@ def on_sample_change():
504
  st.session_state["status_message"] = f"❌ Error loading sample: {e}"
505
  st.session_state["status_type"] = "error"
506
 
 
507
  def on_input_mode_change():
508
  """Reset sample when switching to Upload"""
509
  if st.session_state["input_mode"] == "Upload File":
510
  st.session_state["sample_select"] = "-- Select Sample --"
 
 
 
511
  # πŸ”§ Reset when switching modes to prevent stale right-column visuals
512
  reset_results("Switched input mode")
513
 
 
514
  def on_model_change():
515
  """Force the right column back to init state when the model changes"""
516
  reset_results("Model changed")
517
 
 
518
  def reset_results(reason: str = ""):
519
  """Clear previous inference artifacts so the right column returns to initial state."""
520
  st.session_state["inference_run_once"] = False
521
  st.session_state["x_raw"] = None
522
  st.session_state["y_raw"] = None
523
  st.session_state["y_resampled"] = None
 
 
 
524
  # ||== Clear logs between runs ==||
525
  st.session_state["log_messages"] = []
526
  # ||== Always reset the status box ==||
@@ -530,6 +603,7 @@ def reset_results(reason: str = ""):
530
  )
531
  st.session_state["status_type"] = "info"
532
 
 
533
  def reset_ephemeral_state():
534
  """remove everything except KEPT global UI context"""
535
  for k in list(st.session_state.keys()):
@@ -539,9 +613,9 @@ def reset_ephemeral_state():
539
  # == bump the uploader version β†’ new widget instance with empty value ==
540
  st.session_state["uploader_version"] += 1
541
  st.session_state["current_upload_key"] = f"upload_txt_{st.session_state['uploader_version']}"
542
-
543
  # == reseed other emphemeral state ==
544
- st.session_state["input_text"] = None
545
  st.session_state["filename"] = None
546
  st.session_state["input_source"] = None
547
  st.session_state["sample_select"] = "-- Select Sample --"
@@ -553,10 +627,12 @@ def reset_ephemeral_state():
553
  st.session_state["log_messages"] = []
554
  st.session_state["status_message"] = "Ready to analyze polymer spectra πŸ”¬"
555
  st.session_state["status_type"] = "info"
556
-
557
  st.rerun()
558
 
559
  # Main app
 
 
560
  def main():
561
  init_session_state()
562
 
@@ -564,43 +640,43 @@ def main():
564
  with st.sidebar:
565
  # Header
566
  st.header("AI-Driven Polymer Classification")
567
- st.caption("Predict polymer degradation (Stable vs Weathered) from Raman spectra using validated CNN models. β€” v0.1")
568
- model_labels = [f"{MODEL_CONFIG[name]['emoji']} {name}" for name in MODEL_CONFIG.keys()]
569
- selected_label = st.selectbox("Choose AI Model", model_labels, key="model_select", on_change=on_model_change)
 
 
 
570
  model_choice = selected_label.split(" ", 1)[1]
571
 
572
  # ===Compact metadata directly under dropdown===
573
  render_model_meta(model_choice)
574
 
575
  # ===Collapsed info to reduce clutter===
576
- with st.expander("About This App",icon=":material/info:", expanded=False):
577
  st.markdown("""
578
  AI-Driven Polymer Aging Prediction and Classification
579
 
580
- **Purpose**: Classify polymer degradation using AI
581
  **Input**: Raman spectroscopy `.txt` files
582
  **Models**: CNN architectures for binary classification
583
  **Next**: More trained CNNs in evaluation pipeline
584
 
585
- ---
586
 
587
  **Contributors**
588
  Dr. Sanmukh Kuppannagari (Mentor)
589
  Dr. Metin Karailyan (Mentor)
590
- πŸ‘¨β€πŸ’» Jaser Hasan (Author)
591
 
592
- ---
593
 
594
  **Links**
595
- πŸ”— [Live HF Space](https://huggingface.co/spaces/dev-jas/polymer-aging-ml)
596
- πŸ“‚ [GitHub Repository](https://github.com/KLab-AI3/ml-polymer-recycling)
597
 
598
- ---
599
 
600
  **Citation Figure2CNN (baseline)**
601
  Neo et al., 2023, *Resour. Conserv. Recycl.*, 188, 106718.
602
  [https://doi.org/10.1016/j.resconrec.2022.106718](https://doi.org/10.1016/j.resconrec.2022.106718)
603
- """)
604
 
605
  # Main content area
606
  col1, col2 = st.columns([1, 1.35], gap="small")
@@ -616,7 +692,7 @@ def main():
616
  on_change=on_input_mode_change
617
  )
618
 
619
- #==Upload tab==
620
  if mode == "Upload File":
621
  upload_key = st.session_state["current_upload_key"]
622
  up = st.file_uploader(
@@ -626,36 +702,38 @@ def main():
626
  key=upload_key, # ← versioned key
627
  )
628
 
629
- #==Process change immediately (no on_change; simpler & reliable)==
630
  if up is not None:
631
  raw = up.read()
632
  text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
633
  # == only reparse if its a different file|source ==
634
  if st.session_state.get("filename") != getattr(up, "name", None) or st.session_state.get("input_source") != "upload":
635
- st.session_state["input_text"] = text
636
- st.session_state["filename"] = getattr(up, "name", "uploaded.txt")
637
  st.session_state["input_source"] = "upload"
 
638
  st.session_state["batch_mode"] = False
639
-
640
- # == clear right column immediately ==
641
- reset_results("New file selected")
642
- st.session_state["status_message"] = f"πŸ“ File '{st.session_state['filename']}' ready for analysis"
643
  st.session_state["status_type"] = "success"
644
- #==Batch Upload tab==
 
 
645
  elif mode == "Batch Upload":
646
  st.session_state["batch_mode"] = True
647
  uploaded_files = create_batch_uploader()
648
 
649
  if uploaded_files:
650
- st.success(f"{len(uploaded_files)} files selected for batch processing")
 
651
  st.session_state["batch_files"] = uploaded_files
652
  st.session_state["status_message"] = f"{len(uploaded_files)} ready for batch analysis"
653
  st.session_state["status_type"] = "success"
654
  else:
655
-
656
  st.session_state["batch_files"] = []
657
-
658
- #==Sample tab==
 
 
659
  elif mode == "Sample Data":
660
  st.session_state["batch_mode"] = False
661
  sample_files = get_sample_files()
@@ -666,14 +744,15 @@ def main():
666
  "Choose sample spectrum:",
667
  options,
668
  key="sample_select",
669
- on_change=on_sample_change, # <- critical
670
  )
671
  if sel != "-- Select Sample --":
672
- st.markdown(f"βœ… Loaded sample: {sel}")
 
673
  else:
674
  st.info("No sample data available")
675
 
676
- #==Status box==
677
  msg = st.session_state.get("status_message", "Ready")
678
  typ = st.session_state.get("status_type", "info")
679
  if typ == "success":
@@ -683,19 +762,21 @@ def main():
683
  else:
684
  st.info(msg)
685
 
686
- #==Model load==
687
  model, model_loaded = load_model(model_choice)
688
  if not model_loaded:
689
  st.warning("⚠️ Model weights not available - using demo mode")
690
 
691
- #==Ready to run if we have text (single) or files (batch) and a model==|
692
  is_batch_mode = st.session_state.get("batch_mode", False)
693
  batch_files = st.session_state.get("batch_files", [])
694
 
695
  inference_ready = False # Initialize with a default value
696
  if is_batch_mode:
697
  inference_ready = len(batch_files) > 0 and (model is not None)
698
- button_text = "Run Analysis"
 
 
699
 
700
  # === Run Analysis (form submit batches state) ===
701
  with st.form("analysis_form", clear_on_submit=False):
@@ -708,92 +789,35 @@ def main():
708
  if st.button("Reset", help="Clear current file(s), plots, and results"):
709
  reset_ephemeral_state()
710
 
711
-
712
-
713
  if submitted and inference_ready:
714
  if is_batch_mode:
715
- #==Batch Mode Processing==|
716
-
717
  with st.spinner(f"Processing {len(batch_files)} files ..."):
718
- progress_bar = st.progress(0)
719
- status_text = st.empty()
720
- def progress_callback(current, total, filename):
721
- progress = current / total if total > 0 else 0
722
- progress_bar.progress(progress)
723
-
724
- status_text.text(f"Processing: {filename} ({current}/{total})")
725
-
726
- #=Process all files=
727
  batch_results = process_multiple_files(
728
- batch_files,
729
- model_choice,
730
- load_model,
731
- run_inference,
732
- label_file,
733
- progress_callback
734
  )
735
-
736
- progress_bar.progress(1.0)
737
-
738
- status_text.text("Batch processing complete!")
739
-
740
- #=Update session state=
741
  st.session_state["batch_results"] = batch_results
742
- st.session_state["inference_run_once"] = True
743
- successful_count = sum(1 for r in batch_results if r.get("success", False))
744
- st.session_state["status_message"] = f"Batch analysis completed: {successful_count}/{len(batch_files)} successful"
745
- st.session_state["status_type"] = "success"
746
-
747
- st.rerun()
748
  else:
749
- # === Single File Mode Processing ===
750
- # parse β†’ preprocess β†’ predict β†’ render
751
- # Handles the submission of the analysis form and performs spectrum data processing
752
  try:
753
- raw_text = st.session_state["input_text"]
754
- filename = st.session_state.get("filename") or "unknown.txt"
755
-
756
- # Parse
757
- with st.spinner("Parsing spectrum data..."):
758
- x_raw, y_raw = parse_spectrum_data(raw_text)
759
-
760
- # Resample
761
- with st.spinner("Resampling spectrum..."):
762
- # ===Resample Unpack===
763
- r1, r2 = resample_spectrum(x_raw, y_raw, TARGET_LEN)
764
-
765
- def _is_strictly_increasing(a):
766
- a = np.asarray(a)
767
- return a.ndim == 1 and a.size >= 2 and np.all(np.diff(a) > 0)
768
-
769
- if _is_strictly_increasing(r1) and not _is_strictly_increasing(r2):
770
- x_resampled, y_resampled = np.asarray(r1), np.asarray(r2)
771
- elif _is_strictly_increasing(r2) and not _is_strictly_increasing(r1):
772
- x_resampled, y_resampled = np.asarray(r2), np.asarray(r1)
773
- else:
774
- # == Ambigous; assume (x, y) and log
775
- x_resampled, y_resampled = np.asarray(r1), np.asarray(r2)
776
- log_message("Resample outputs ambigous; assumed (x, y).")
777
-
778
- # ===Persists for plotting + inference===
779
- st.session_state["x_raw"] = x_raw
780
- st.session_state["y_raw"] = y_raw
781
- st.session_state["x_resampled"] = x_resampled # ←-- NEW
782
- st.session_state["y_resampled"] = y_resampled
783
-
784
- # Persist results (drives right column)
785
  st.session_state["x_raw"] = x_raw
786
  st.session_state["y_raw"] = y_raw
 
787
  st.session_state["y_resampled"] = y_resampled
788
  st.session_state["inference_run_once"] = True
789
- st.session_state["status_message"] = f"πŸ” Analysis completed for: {filename}"
790
- st.session_state["status_type"] = "success"
791
-
792
- st.rerun()
793
-
794
  except (ValueError, TypeError) as e:
795
- ErrorHandler.log_error(e, "Single file analysis")
796
- st.error(f"❌ Analysis failed: {e}")
797
  st.session_state["status_message"] = f"❌ Error: {e}"
798
  st.session_state["status_type"] = "error"
799
 
@@ -827,16 +851,20 @@ def main():
827
  if all(v is not None for v in [x_raw, y_raw, y_resampled]):
828
  # ===Run inference===
829
  if y_resampled is None:
830
- raise ValueError("y_resampled is None. Ensure spectrum data is properly resampled before proceeding.")
831
- cache_key = hashlib.md5(f"{y_resampled.tobytes()}{model_choice}".encode()).hexdigest()
 
 
832
  prediction, logits_list, probs, inference_time, logits = run_inference(
833
  y_resampled, model_choice, _cache_key=cache_key
834
  )
835
  if prediction is None:
836
- st.error("❌ Inference failed: Model not loaded. Please check that weights are available.")
 
837
  st.stop() # prevents the rest of the code in this block from executing
838
 
839
- log_message(f"Inference completed in {inference_time:.2f}s, prediction: {prediction}")
 
840
 
841
  # ===Get ground truth===
842
  true_label_idx = label_file(filename)
@@ -846,17 +874,19 @@ def main():
846
  predicted_class = LABEL_MAP.get(
847
  int(prediction), f"Class {int(prediction)}")
848
 
849
-
850
  # Enhanced confidence calculation
851
  if logits is not None:
852
  # Use new softmax-based confidence
853
- probs_np, max_confidence, confidence_level, confidence_emoji = calculate_softmax_confidence(logits)
 
854
  confidence_desc = confidence_level
855
  else:
856
  # Fallback to legace method
857
- logit_margin = abs((logits_list[0] - logits_list[1]) if logits_list is not None and len(logits_list) >= 2 else 0)
858
- confidence_desc, confidence_emoji = get_confidence_description(logit_margin)
859
- max_confidence = logit_margin / 10.0 # Normalize for display
 
 
860
  probs_np = np.array([])
861
 
862
  # Store result in results manager for single file too
@@ -875,7 +905,7 @@ def main():
875
  }
876
  )
877
 
878
- #===Precompute Stats===
879
  spec_stats = {
880
  "Original Length": len(x_raw) if x_raw is not None else 0,
881
  "Resampled Length": TARGET_LEN,
@@ -884,13 +914,15 @@ def main():
884
  "Confidence Bucket": confidence_desc,
885
  }
886
  model_path = MODEL_CONFIG[model_choice]["path"]
887
- mtime = os.path.getmtime(model_path) if os.path.exists(model_path) else None
 
888
  file_hash = (
889
  hashlib.md5(open(model_path, 'rb').read()).hexdigest()
890
  if os.path.exists(model_path) else "N/A"
891
  )
892
- input_tensor = torch.tensor(y_resampled, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
893
- model_stats = {
 
894
  "Architecture": model_choice,
895
  "Model Path": model_path,
896
  "Weights Last Modified": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(mtime)) if mtime else "N/A",
@@ -909,98 +941,342 @@ def main():
909
  ["Details", "Technical", "Explanation"],
910
  key="active_tab", # reuse the key you were managing manually
911
  )
912
-
913
  if active_tab == "Details":
914
- with st.container():
915
- st.markdown(f"""
916
- **Sample**: `{filename}`
917
- **Model**: `{model_choice}`
918
- **Processing Time**: `{inference_time:.2f}s`
919
- """)
920
- st.markdown("<div class='expander-marker expander-success'></div>", unsafe_allow_html=True)
921
- with st.expander("Prediction/Ground Truth & Model Confidence Margin", expanded=True):
922
- if predicted_class == "Stable (Unweathered)":
923
- st.markdown(f"🟒 **Prediction**: {predicted_class}")
924
- else:
925
- st.markdown(f"🟑 **Prediction**: {predicted_class}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  st.markdown(
927
- f"**{confidence_emoji} Confidence**: {confidence_desc} ({max_confidence:.1%})")
928
- if true_label_idx is not None:
929
- if predicted_class == true_label_str:
930
- st.markdown(
931
- f"βœ… **Ground Truth**: {true_label_str} - **Correct!**")
932
- else:
933
- st.markdown(
934
- f"❌ **Ground Truth**: {true_label_str} - **Incorrect**")
935
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  st.markdown(
937
- "**Ground Truth**: Unknown (filename doesn't follow naming convention)")
938
-
939
- st.markdown("###### Confidence Overview")
940
- if len(probs_np) > 0:
941
- confidence_html = create_confidence_progress_html(
942
- probs_np,
943
- labels=["Stable", "Weathered"],
944
- highlight_idx=int(prediction)
945
- )
946
- st.markdown(confidence_html, unsafe_allow_html=True)
947
- else:
948
- # Fallback to legacy method
949
- render_confidence_progress(
950
- probs if probs is not None else np.array([]),
951
- labels=["Stable", "Weathered"],
952
- highlight_idx=int(prediction),
953
- side_by_side=True, # Set false for stacked <<
 
 
 
 
 
 
 
 
954
  )
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  elif active_tab == "Technical":
957
  with st.container():
958
- st.markdown("<div class='expander-marker expander-success'></div>", unsafe_allow_html=True)
959
- with st.expander("Diagnostics/Technical Info (advanced)", expanded=True):
960
- st.markdown("###### Model Output (Logits)")
961
- cols = st.columns(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
962
  if logits_list is not None:
963
- for i, score in enumerate(logits_list):
964
- label = LABEL_MAP.get(i, f"Class {i}")
965
- cols[i % 2].metric(label, f"{score:.2f}")
966
- st.markdown("###### Spectrum Statistics")
967
- render_kv_grid(spec_stats, ncols=2)
968
- st.markdown("---")
969
- st.markdown("###### Model Statistics")
970
- render_kv_grid(model_stats, ncols=2)
971
- st.markdown("---")
972
- st.markdown("###### Debug Log")
973
- st.text_area("Logs", "\n".join(st.session_state.get("log_messages", [])), height=110)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
974
 
975
  elif active_tab == "Explanation":
976
  with st.container():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  st.markdown("""
978
- **πŸ” Analysis Process**
979
-
980
- 1. **Data Upload**: Raman spectrum file loaded
981
- 2. **Preprocessing**: Data parsed and resampled to 500 points
982
- 3. **AI Inference**: CNN model analyzes spectral patterns
983
- 4. **Classification**: Binary prediction with confidence scores
984
-
985
- **🧠 Model Interpretation**
986
 
987
- The AI model identifies spectral features indicative of:
988
- - **Stable polymers**: Well-preserved molecular structure
989
- - **Weathered polymers**: Degraded/oxidized molecular bonds
 
990
 
991
- **🎯 Applications**
992
-
993
- - Material longevity assessment
994
- - Recycling viability evaluation
995
- - Quality control in manufacturing
996
- - Environmental impact studies
997
  """)
998
 
999
- render_time = time.time() - start_render
1000
- log_message(f"col2 rendered in {render_time:.2f}s, active tab: {active_tab}")
 
1001
 
1002
- st.markdown("<div class='expander-marker expander-success'></div>", unsafe_allow_html=True)
1003
  with st.expander("Spectrum Preprocessing Results", expanded=False):
 
 
 
 
 
 
 
 
 
 
1004
  # Create and display plot
1005
  cache_key = hashlib.md5(
1006
  f"{(x_raw.tobytes() if x_raw is not None else b'')}"
@@ -1008,8 +1284,10 @@ def main():
1008
  f"{(x_resampled.tobytes() if x_resampled is not None else b'')}"
1009
  f"{(y_resampled.tobytes() if y_resampled is not None else b'')}".encode()
1010
  ).hexdigest()
1011
- spectrum_plot = create_spectrum_plot(x_raw, y_raw, x_resampled, y_resampled, _cache_key=cache_key)
1012
- st.image(spectrum_plot, caption="Spectrum Preprocessing Results", use_container_width=True)
 
 
1013
 
1014
  else:
1015
  st.error(
 
1
+ from typing import Union
2
+ from utils.multifile import create_batch_uploader, process_multiple_files, display_batch_results
3
+ from utils.confidence import calculate_softmax_confidence, get_confidence_badge, create_confidence_progress_html
4
+ from utils.results_manager import ResultsManager
5
+ from utils.errors import ErrorHandler, safe_execute
6
+ from utils.preprocessing import resample_spectrum
7
  from models.resnet_cnn import ResNet1D
8
  from models.figure2_cnn import Figure2CNN
9
  import hashlib
 
27
  sys.path.append(str(utils_path))
28
  matplotlib.use("Agg") # ensure headless rendering in Spaces
29
 
30
+ # ==Import local modules + new modules==
 
 
 
 
 
31
 
32
  KEEP_KEYS = {
33
  # ==global UI context we want to keep after "Reset"==
34
  "model_select", # sidebar model key
35
  "input_mode", # radio for Upload|Sample
36
+ "uploader_version", # version counter for file uploader
37
  "input_registry", # radio controlling Upload vs Sample
38
  }
39
 
40
+ # ==Page Configuration==
41
  st.set_page_config(
42
  page_title="ML Polymer Classification",
43
  page_icon="πŸ”¬",
44
  layout="wide",
45
+ initial_sidebar_state="expanded",
46
+ menu_items={
47
+ "Get help": "https://github.com/KLab-AI3/ml-polymer-recycling"}
48
  )
49
 
50
+ # ==Custom CSS Page + Element Styling==
51
  st.markdown("""
52
  <style>
53
+ /* Modern, slightly darker custom CSS for Streamlit app */
54
+ /* Optimized for accessibility, consistency, and tech-forward aesthetics */
55
+
56
+ /* Scoped global styles */
57
+ :where(html, body, .stApp) {
58
+ background-color: #111827; /* Tailwind gray-900, dark and sleek */
59
+ color: #fff; /* Tailwind slate-100, high contrast */
60
+ font-family: 'roboto', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
61
+ font-size: 16px; /* Base font size for accessibility */
62
+ line-height: 1.4;
63
+ }
64
 
65
+ /* Tabs content area */
66
+ div[data-testid="stTabs"] > div[role="tablist"] + div {
67
+ min-height: 400px;
68
+ background: #1f2937; /* Tailwind gray-800, slightly lighter for depth */
69
+ border-radius: 8px;
70
+ padding: 20px;
71
+ }
72
 
73
+ /* Confidence box */
74
  .confbox {
75
+ font-size: 0.9rem;
76
+ padding: 10px 12px;
77
+ border: 1px solid #374151; /* Tailwind gray-700 */
78
+ border-radius: 6px;
79
+ background: #1e293b; /* Tailwind slate-800 */
80
+ color: #d1d5db; /* Tailwind gray-300 */
81
  }
82
 
83
+ /* Key-value rows */
84
+ .kv-row {
85
+ display: flex;
86
+ justify-content: space-between;
87
+ gap: 16px;
88
+ padding: 4px 0;
89
+ border-bottom: 1px solid #374151; /* Tailwind gray-700 */
90
+ }
91
+ .kv-key {
92
+ opacity: 0.8;
93
+ font-size: 0.9rem;
94
+ white-space: nowrap;
95
+ }
96
+ .kv-val {
97
+ font-family: 'Fira Code', monospace;
98
+ font-size: 0.9rem;
99
+ color: #e5e7eb; /* Tailwind gray-200 */
100
+ overflow-wrap: break-word;
101
+ }
102
 
103
+ /* Markdown headings */
104
+ :where(h5, .stMarkdown h5) {
105
+ margin-top: 0.5rem;
106
+ color: #f1f5f9; /* Tailwind slate-100 */
107
+ font-weight: 500;
108
+ }
109
 
110
+ /* Expander styles */
111
  div.stExpander > details > summary {
112
  display: flex;
113
  align-items: center;
114
  justify-content: space-between;
 
115
  cursor: pointer;
116
+ border: 1px solid #374151; /* Tailwind gray-700 */
 
117
  border-radius: 6px;
118
+ padding: 6px 10px;
119
+ margin: 0;
120
+ background: #1e293b; /* Tailwind slate-800 */
121
  font-weight: 600;
122
  font-size: 0.95rem;
123
+ color: #d1d5db; /* Tailwind gray-300 */
124
  }
125
 
126
+ /* Remove default disclosure markers */
127
+ div.stExpander > details > summary::-webkit-details-marker,
 
 
128
  div.stExpander > details > summary::marker {
129
  display: none;
130
  }
131
 
132
+ /* Hide Streamlit's custom arrow icon */
 
 
 
 
 
133
  div[data-testid="stExpander"] summary svg {
134
  display: none !important;
135
  }
136
 
137
+ /* Expander hover state */
138
+ div.stExpander > details[open] > summary {
139
+ background: #374151; /* Tailwind gray-700 */
140
+ }
141
+
142
+ /* Expander badge */
143
  div.stExpander > details > summary::after {
144
+ content: " ↓ ";
145
+ font-size: 1.2rem;
146
+ font-weight: 1000;
147
+ letter-spacing: 0.5 ;
148
+ padding: 3px 10px;
149
+ border: 1px solid #4b5563;
150
  border-radius: 999px;
151
+ background: #374151; /* Tailwind gray-600 */
152
+ color: #e5e7eb; /* Tailwind gray-200 */
 
153
  }
154
 
155
+ /* Success/results expander */
156
  .expander-marker + div[data-testid="stExpander"] summary {
157
+ border-left-color: #059669; /* Tailwind emerald-600 */
158
+ background: #1e293b; /* Tailwind slate-800 */
159
  }
160
  .expander-marker + div[data-testid="stExpander"] summary::after {
161
  content: "RESULTS";
162
+ background: #047857; /* Tailwind emerald-700 */
163
+ color: #d1fae5; /* Tailwind emerald-100 */
164
  }
165
 
166
+ [data-testid="stExpanderDetails"] {
167
+ padding-top: 10px;
168
+ }
169
 
170
+ /* Technical expander */
171
  div.stExpander:has(summary:contains("Technical")) > details > summary {
172
+ border-left-color: #ea580c; /* Tailwind orange-600 */
173
+ background: #1e293b; /* Tailwind slate-800 */
174
  }
175
  div.stExpander:has(summary:contains("Technical")) > details > summary::after {
176
  content: "ADVANCED";
177
+ background: #c2410c; /* Tailwind orange-700 */
178
+ color: #ffedd5; /* Tailwind orange-100 */
179
  }
180
 
181
+ /* Sidebar metrics */
 
 
182
  div[data-testid="stMetricValue"] {
183
+ font-size: 0.95rem !important;
184
+ color: #f1f5f9; /* Tailwind slate-100 */
185
  }
186
  div[data-testid="stMetricLabel"] {
187
  font-size: 0.85rem !important;
188
+ opacity: 0.8;
189
+ color: #d1d5db; /* Tailwind gray-300 */
190
  }
191
 
192
+ /* Sidebar text */
193
+ section[data-testid="stSidebar"]{
194
  font-size: 0.95rem !important;
195
+ line-height: 1.25;
196
+ color: #fff; /* Tailwind gray-200 */
197
  }
198
 
199
+ /* Diagnostics metrics */
200
  div[data-testid="stMetricValue"] {
201
  font-size: 0.95rem !important;
202
+ color: #f1f5f9; /* Tailwind slate-100 */
203
  }
204
  div[data-testid="stMetricLabel"] {
205
  font-size: 0.85rem !important;
206
+ color: #d1d5db; /* Tailwind gray-300 */
207
  }
 
 
208
  </style>
209
  """, unsafe_allow_html=True)
210
 
211
 
212
+ # ==CONSTANTS==
213
  TARGET_LEN = 500
214
  SAMPLE_DATA_DIR = Path("sample_data")
215
  # Prefer env var, else 'model_weights' if present; else canonical 'outputs'
 
238
  }
239
  }
240
 
241
+ # ==Label mapping==
242
  LABEL_MAP = {0: "Stable (Unweathered)", 1: "Weathered (Degraded)"}
243
 
244
 
245
+ # ==UTILITY FUNCTIONS==
246
  def init_session_state():
247
  """Keep a persistent session state"""
248
  defaults = {
 
259
  "uploader_version": 0,
260
  "current_upload_key": "upload_txt_0",
261
  "active_tab": "Details",
262
+ "batch_mode": False # Track if in batch mode
263
  }
264
  for k, v in defaults.items():
265
  st.session_state.setdefault(k, v)
 
268
  if key not in st.session_state:
269
  st.session_state[key] = default_value
270
 
271
+ # ==Initialize results table==
272
  ResultsManager.init_results_table()
273
 
274
 
 
288
  def load_state_dict(_mtime, model_path):
289
  """Load state dict with mtime in cache key to detect file changes"""
290
  try:
291
+ return torch.load(model_path, map_location="cpu")
292
  except (FileNotFoundError, RuntimeError) as e:
293
  st.warning(f"Error loading state dict: {e}")
294
  return None
 
326
  else:
327
  return model, False
328
 
329
+ except (FileNotFoundError, KeyError, RuntimeError) as e:
330
  st.error(f"❌ Error loading model {model_name}: {str(e)}")
331
  return None, False
332
 
 
337
  if torch.cuda.is_available():
338
  torch.cuda.empty_cache()
339
 
340
+
341
  @st.cache_data
342
  def run_inference(y_resampled, model_choice, _cache_key=None):
343
  """Run model inference and cache results"""
 
345
  if not model_loaded:
346
  return None, None, None, None, None
347
 
348
+ input_tensor = torch.tensor(
349
+ y_resampled, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
350
  start_time = time.time()
351
  model.eval()
352
  with torch.no_grad():
353
  if model is None:
354
+ raise ValueError(
355
+ "Model is not loaded. Please check the model configuration or weights.")
356
  logits = model(input_tensor)
357
  prediction = torch.argmax(logits, dim=1).item()
358
  logits_list = logits.detach().numpy().tolist()[0]
 
417
 
418
  return x, y
419
 
420
+
421
  @st.cache_data
422
  def create_spectrum_plot(x_raw, y_raw, x_resampled, y_resampled, _cache_key=None):
423
  """Create spectrum visualization plot"""
 
432
  ax[0].legend()
433
 
434
  # == Resampled spectrum ==
435
+ ax[1].plot(x_resampled, y_resampled, label="Resampled",
436
+ color="steelblue", linewidth=1)
437
  ax[1].set_title(f"Resampled ({len(y_resampled)} points)")
438
  ax[1].set_xlabel("Wavenumber (cm⁻¹)")
439
  ax[1].set_ylabel("Intensity")
 
449
 
450
  return Image.open(buf)
451
 
 
452
 
453
  def render_confidence_progress(
454
  probs: np.ndarray,
 
456
  highlight_idx: Union[int, None] = None,
457
  side_by_side: bool = True
458
  ):
459
+ """Render Streamlit native progress bars with scientific formatting."""
 
460
  p = np.asarray(probs, dtype=float)
461
  p = np.clip(p, 0.0, 1.0)
462
 
 
 
 
 
463
  if side_by_side:
464
  cols = st.columns(len(labels))
465
  for i, (lbl, val, col) in enumerate(zip(labels, p, cols)):
466
  with col:
467
+ is_highlighted = (
468
+ highlight_idx is not None and i == highlight_idx)
469
+ label_text = f"**{lbl}**" if is_highlighted else lbl
470
+ st.markdown(f"{label_text}: {val*100:.1f}%")
471
  st.progress(int(round(val * 100)))
472
  else:
473
+ # Vertical layout for better readability
474
  for i, (lbl, val) in enumerate(zip(labels, p)):
475
+ is_highlighted = (highlight_idx is not None and i == highlight_idx)
476
+
477
+ # Create a container for each probability
478
+ with st.container():
479
+ col1, col2 = st.columns([3, 1])
480
+ with col1:
481
+ if is_highlighted:
482
+ st.markdown(f"**{lbl}** ← Predicted")
483
+ else:
484
+ st.markdown(f"{lbl}")
485
+ with col2:
486
+ st.metric(
487
+ label="",
488
+ value=f"{val*100:.1f}%",
489
+ delta=None
490
+ )
491
+
492
+ # Progress bar with conditional styling
493
+ if is_highlighted:
494
+ st.progress(int(round(val * 100)))
495
+ st.caption("🎯 **Model Prediction**")
496
+ else:
497
+ st.progress(int(round(val * 100)))
498
+
499
+ if i < len(labels) - 1: # Add spacing between items
500
+ st.markdown("")
501
 
502
 
503
  def render_kv_grid(d: dict, ncols: int = 2):
504
+ """Display dict as a clean grid of key/value rows using native Streamlit components."""
505
+ if not d:
506
  return
507
  items = list(d.items())
508
  cols = st.columns(ncols)
509
  for i, (k, v) in enumerate(items):
510
  with cols[i % ncols]:
511
+ st.caption(f"**{k}:** {v}")
 
 
 
 
 
 
512
 
513
 
514
  def render_model_meta(model_choice: str):
 
539
  else:
540
  return "LOW", "πŸ”΄"
541
 
542
+
543
  def log_message(msg: str):
544
  """Append a timestamped line to the in-app log, creating the buffer if needed."""
545
  ErrorHandler.log_info(msg)
546
 
547
+
548
  def trigger_run():
549
  """Set a flag so we can detect button press reliably across reruns"""
550
  st.session_state['run_requested'] = True
551
 
552
+
553
  def on_sample_change():
554
  """Read selected sample once and persist as text."""
555
  sel = st.session_state.get("sample_select", "-- Select Sample --")
 
568
  st.session_state["status_message"] = f"❌ Error loading sample: {e}"
569
  st.session_state["status_type"] = "error"
570
 
571
+
572
  def on_input_mode_change():
573
  """Reset sample when switching to Upload"""
574
  if st.session_state["input_mode"] == "Upload File":
575
  st.session_state["sample_select"] = "-- Select Sample --"
576
+ st.session_state["batch_mode"] = False # Reset batch mode
577
+ elif st.session_state["input_mode"] == "Sample Data":
578
+ st.session_state["batch_mode"] = False # Reset batch mode
579
  # πŸ”§ Reset when switching modes to prevent stale right-column visuals
580
  reset_results("Switched input mode")
581
 
582
+
583
  def on_model_change():
584
  """Force the right column back to init state when the model changes"""
585
  reset_results("Model changed")
586
 
587
+
588
  def reset_results(reason: str = ""):
589
  """Clear previous inference artifacts so the right column returns to initial state."""
590
  st.session_state["inference_run_once"] = False
591
  st.session_state["x_raw"] = None
592
  st.session_state["y_raw"] = None
593
  st.session_state["y_resampled"] = None
594
+ # ||== Clear batch results when resetting ==||
595
+ if "batch_results" in st.session_state:
596
+ del st.session_state["batch_results"]
597
  # ||== Clear logs between runs ==||
598
  st.session_state["log_messages"] = []
599
  # ||== Always reset the status box ==||
 
603
  )
604
  st.session_state["status_type"] = "info"
605
 
606
+
607
  def reset_ephemeral_state():
608
  """remove everything except KEPT global UI context"""
609
  for k in list(st.session_state.keys()):
 
613
  # == bump the uploader version β†’ new widget instance with empty value ==
614
  st.session_state["uploader_version"] += 1
615
  st.session_state["current_upload_key"] = f"upload_txt_{st.session_state['uploader_version']}"
616
+
617
  # == reseed other emphemeral state ==
618
+ st.session_state["input_text"] = None
619
  st.session_state["filename"] = None
620
  st.session_state["input_source"] = None
621
  st.session_state["sample_select"] = "-- Select Sample --"
 
627
  st.session_state["log_messages"] = []
628
  st.session_state["status_message"] = "Ready to analyze polymer spectra πŸ”¬"
629
  st.session_state["status_type"] = "info"
630
+
631
  st.rerun()
632
 
633
  # Main app
634
+
635
+
636
  def main():
637
  init_session_state()
638
 
 
640
  with st.sidebar:
641
  # Header
642
  st.header("AI-Driven Polymer Classification")
643
+ st.caption(
644
+ "Predict polymer degradation (Stable vs Weathered) from Raman spectra using validated CNN models. β€” v0.1")
645
+ model_labels = [
646
+ f"{MODEL_CONFIG[name]['emoji']} {name}" for name in MODEL_CONFIG.keys()]
647
+ selected_label = st.selectbox(
648
+ "Choose AI Model", model_labels, key="model_select", on_change=on_model_change)
649
  model_choice = selected_label.split(" ", 1)[1]
650
 
651
  # ===Compact metadata directly under dropdown===
652
  render_model_meta(model_choice)
653
 
654
  # ===Collapsed info to reduce clutter===
655
+ with st.expander("About This App", icon=":material/info:", expanded=False):
656
  st.markdown("""
657
  AI-Driven Polymer Aging Prediction and Classification
658
 
659
+ **Purpose**: Classify polymer degradation using AI
660
  **Input**: Raman spectroscopy `.txt` files
661
  **Models**: CNN architectures for binary classification
662
  **Next**: More trained CNNs in evaluation pipeline
663
 
 
664
 
665
  **Contributors**
666
  Dr. Sanmukh Kuppannagari (Mentor)
667
  Dr. Metin Karailyan (Mentor)
668
+ Jaser Hasan (Author)
669
 
 
670
 
671
  **Links**
672
+ [Live HF Space](https://huggingface.co/spaces/dev-jas/polymer-aging-ml)
673
+ [GitHub Repository](https://github.com/KLab-AI3/ml-polymer-recycling)
674
 
 
675
 
676
  **Citation Figure2CNN (baseline)**
677
  Neo et al., 2023, *Resour. Conserv. Recycl.*, 188, 106718.
678
  [https://doi.org/10.1016/j.resconrec.2022.106718](https://doi.org/10.1016/j.resconrec.2022.106718)
679
+ """, )
680
 
681
  # Main content area
682
  col1, col2 = st.columns([1, 1.35], gap="small")
 
692
  on_change=on_input_mode_change
693
  )
694
 
695
+ # ==Upload tab==
696
  if mode == "Upload File":
697
  upload_key = st.session_state["current_upload_key"]
698
  up = st.file_uploader(
 
702
  key=upload_key, # ← versioned key
703
  )
704
 
705
+ # ==Process change immediately (no on_change; simpler & reliable)==
706
  if up is not None:
707
  raw = up.read()
708
  text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
709
  # == only reparse if its a different file|source ==
710
  if st.session_state.get("filename") != getattr(up, "name", None) or st.session_state.get("input_source") != "upload":
711
+ st.session_state["input_text"] = text
712
+ st.session_state["filename"] = getattr(up, "name", None)
713
  st.session_state["input_source"] = "upload"
714
+ # Ensure single file mode
715
  st.session_state["batch_mode"] = False
716
+ st.session_state["status_message"] = f"File '{st.session_state['filename']}' ready for analysis"
 
 
 
717
  st.session_state["status_type"] = "success"
718
+ reset_results("New file uploaded")
719
+
720
+ # ==Batch Upload tab==
721
  elif mode == "Batch Upload":
722
  st.session_state["batch_mode"] = True
723
  uploaded_files = create_batch_uploader()
724
 
725
  if uploaded_files:
726
+ st.success(
727
+ f"{len(uploaded_files)} files selected for batch processing")
728
  st.session_state["batch_files"] = uploaded_files
729
  st.session_state["status_message"] = f"{len(uploaded_files)} ready for batch analysis"
730
  st.session_state["status_type"] = "success"
731
  else:
 
732
  st.session_state["batch_files"] = []
733
+ st.session_state["status_message"] = "No files selected for batch processing"
734
+ st.session_state["status_type"] = "info"
735
+
736
+ # ==Sample tab==
737
  elif mode == "Sample Data":
738
  st.session_state["batch_mode"] = False
739
  sample_files = get_sample_files()
 
744
  "Choose sample spectrum:",
745
  options,
746
  key="sample_select",
747
+ on_change=on_sample_change,
748
  )
749
  if sel != "-- Select Sample --":
750
+ st.session_state["status_message"] = f"πŸ“ Sample '{sel}' ready for analysis"
751
+ st.session_state["status_type"] = "success"
752
  else:
753
  st.info("No sample data available")
754
 
755
+ # ==Status box==
756
  msg = st.session_state.get("status_message", "Ready")
757
  typ = st.session_state.get("status_type", "info")
758
  if typ == "success":
 
762
  else:
763
  st.info(msg)
764
 
765
+ # ==Model load==
766
  model, model_loaded = load_model(model_choice)
767
  if not model_loaded:
768
  st.warning("⚠️ Model weights not available - using demo mode")
769
 
770
+ # ==Ready to run if we have text (single) or files (batch) and a model==|
771
  is_batch_mode = st.session_state.get("batch_mode", False)
772
  batch_files = st.session_state.get("batch_files", [])
773
 
774
  inference_ready = False # Initialize with a default value
775
  if is_batch_mode:
776
  inference_ready = len(batch_files) > 0 and (model is not None)
777
+ else:
778
+ inference_ready = st.session_state.get(
779
+ "input_text") is not None and (model is not None)
780
 
781
  # === Run Analysis (form submit batches state) ===
782
  with st.form("analysis_form", clear_on_submit=False):
 
789
  if st.button("Reset", help="Clear current file(s), plots, and results"):
790
  reset_ephemeral_state()
791
 
 
 
792
  if submitted and inference_ready:
793
  if is_batch_mode:
 
 
794
  with st.spinner(f"Processing {len(batch_files)} files ..."):
795
+ try:
 
 
 
 
 
 
 
 
796
  batch_results = process_multiple_files(
797
+ uploaded_files=batch_files,
798
+ model_choice=model_choice,
799
+ load_model_func=load_model,
800
+ run_inference_func=run_inference,
801
+ label_file_func=label_file
 
802
  )
 
 
 
 
 
 
803
  st.session_state["batch_results"] = batch_results
804
+ st.success(
805
+ f"Successfully processed {len([r for r in batch_results if r.get('success', False)])}/{len(batch_files)} files")
806
+ except Exception as e:
807
+ st.error(f"Error during batch processing: {e}")
 
 
808
  else:
 
 
 
809
  try:
810
+ x_raw, y_raw = parse_spectrum_data(
811
+ st.session_state["input_text"])
812
+ x_resampled, y_resampled = resample_spectrum(
813
+ x_raw, y_raw, TARGET_LEN)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  st.session_state["x_raw"] = x_raw
815
  st.session_state["y_raw"] = y_raw
816
+ st.session_state["x_resampled"] = x_resampled
817
  st.session_state["y_resampled"] = y_resampled
818
  st.session_state["inference_run_once"] = True
 
 
 
 
 
819
  except (ValueError, TypeError) as e:
820
+ st.error(f"Error processing spectrum data: {e}")
 
821
  st.session_state["status_message"] = f"❌ Error: {e}"
822
  st.session_state["status_type"] = "error"
823
 
 
851
  if all(v is not None for v in [x_raw, y_raw, y_resampled]):
852
  # ===Run inference===
853
  if y_resampled is None:
854
+ raise ValueError(
855
+ "y_resampled is None. Ensure spectrum data is properly resampled before proceeding.")
856
+ cache_key = hashlib.md5(
857
+ f"{y_resampled.tobytes()}{model_choice}".encode()).hexdigest()
858
  prediction, logits_list, probs, inference_time, logits = run_inference(
859
  y_resampled, model_choice, _cache_key=cache_key
860
  )
861
  if prediction is None:
862
+ st.error(
863
+ "❌ Inference failed: Model not loaded. Please check that weights are available.")
864
  st.stop() # prevents the rest of the code in this block from executing
865
 
866
+ log_message(
867
+ f"Inference completed in {inference_time:.2f}s, prediction: {prediction}")
868
 
869
  # ===Get ground truth===
870
  true_label_idx = label_file(filename)
 
874
  predicted_class = LABEL_MAP.get(
875
  int(prediction), f"Class {int(prediction)}")
876
 
 
877
  # Enhanced confidence calculation
878
  if logits is not None:
879
  # Use new softmax-based confidence
880
+ probs_np, max_confidence, confidence_level, confidence_emoji = calculate_softmax_confidence(
881
+ logits)
882
  confidence_desc = confidence_level
883
  else:
884
  # Fallback to legace method
885
+ logit_margin = abs(
886
+ (logits_list[0] - logits_list[1]) if logits_list is not None and len(logits_list) >= 2 else 0)
887
+ confidence_desc, confidence_emoji = get_confidence_description(
888
+ logit_margin)
889
+ max_confidence = logit_margin / 10.0 # Normalize for display
890
  probs_np = np.array([])
891
 
892
  # Store result in results manager for single file too
 
905
  }
906
  )
907
 
908
+ # ===Precompute Stats===
909
  spec_stats = {
910
  "Original Length": len(x_raw) if x_raw is not None else 0,
911
  "Resampled Length": TARGET_LEN,
 
914
  "Confidence Bucket": confidence_desc,
915
  }
916
  model_path = MODEL_CONFIG[model_choice]["path"]
917
+ mtime = os.path.getmtime(
918
+ model_path) if os.path.exists(model_path) else None
919
  file_hash = (
920
  hashlib.md5(open(model_path, 'rb').read()).hexdigest()
921
  if os.path.exists(model_path) else "N/A"
922
  )
923
+ input_tensor = torch.tensor(
924
+ y_resampled, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
925
+ model_stats = {
926
  "Architecture": model_choice,
927
  "Model Path": model_path,
928
  "Weights Last Modified": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(mtime)) if mtime else "N/A",
 
941
  ["Details", "Technical", "Explanation"],
942
  key="active_tab", # reuse the key you were managing manually
943
  )
944
+
945
  if active_tab == "Details":
946
+ with st.expander("Results", expanded=True):
947
+ # Clean header with key information
948
+ st.markdown("<br>**Analysis Summary**",
949
+ width="content", unsafe_allow_html=True)
950
+
951
+ # Streamlined header information
952
+ header_col1, header_col2, header_col3 = st.columns([
953
+ 2, 2, 2], border=True)
954
+
955
+ with header_col1:
956
+ st.metric(
957
+ label="**Sample**",
958
+ value=filename,
959
+ delta=None,
960
+ )
961
+
962
+ with header_col2:
963
+ st.metric(
964
+ label="**Model**",
965
+ value=model_choice.split(
966
+ " ")[0], # Remove emoji
967
+ delta=None
968
+ )
969
+
970
+ with header_col3:
971
+ st.metric(
972
+ label="**Processing Time**",
973
+ value=f"{inference_time:.2f}s",
974
+ delta=None
975
+ )
976
+ # Main classification results in clean cards
977
+ st.markdown("**Classification Results**",
978
+ width="content", unsafe_allow_html=True)
979
+
980
+ # Primary results in a clean 3-column layout
981
+ result_col1, result_col2, result_col3 = st.columns([
982
+ 1, 1, 1], border=True)
983
+
984
+ with result_col1:
985
+ st.metric(
986
+ label="**Prediction**",
987
+ value=predicted_class,
988
+ delta=None
989
+ )
990
+
991
+ with result_col2:
992
+ confidence_icon = "🟒" if max_confidence >= 0.8 else "🟑" if max_confidence >= 0.6 else "πŸ”΄"
993
+ st.metric(
994
+ label="**Confidence**",
995
+ value=f"{confidence_icon} {max_confidence:.1%}",
996
+ delta=None
997
+ )
998
+
999
+ with result_col3:
1000
+ st.metric(
1001
+ label="**Ground Truth**",
1002
+ value=f"{true_label_str}",
1003
+ delta=None
1004
+ )
1005
+
1006
+ # Enhanced confidence analysis - more compact and scientific
1007
+ # Create a professional confidence display
1008
+ with st.container(border=True, height=325):
1009
  st.markdown(
1010
+ "**Confidence Analysis**", unsafe_allow_html=True)
1011
+ # Function to create enhanced bullet bars
1012
+
1013
+ def create_bullet_bar(probability, width=20, predicted=False):
1014
+ filled_count = int(probability * width)
1015
+ empty_count = width - filled_count
1016
+
1017
+ # Use professional symbols
1018
+ filled_symbol = "β–ˆ " # Solid block
1019
+ empty_symbol = "β–‘" # Light shade
1020
+
1021
+ # Create the bar
1022
+ bar = filled_symbol * filled_count + empty_symbol * empty_count
1023
+
1024
+ # Add percentage with scientific formatting
1025
+ percentage = f"{probability:.1%}"
1026
+
1027
+ # Add prediction indicator
1028
+ pred_marker = "↩ Predicted" if predicted else ""
1029
+
1030
+ return f"{bar} {percentage} {pred_marker}"
1031
+
1032
+ # Get probabilities
1033
+ stable_prob = probs[0]
1034
+ weathered_prob = probs[1]
1035
+ is_stable_predicted = int(prediction) == 0
1036
+ is_weathered_predicted = int(prediction) == 1
1037
+
1038
+ # Clean 2-column layout for assessment and probabilities
1039
+ assess_col, prob_col = st.columns(
1040
+ [1, 2.5], gap="small", border=True)
1041
+
1042
+ # Left column: Assessment metrics
1043
+ with assess_col:
1044
  st.markdown(
1045
+ "Assessment", unsafe_allow_html=True)
1046
+
1047
+ # Ground truth validation
1048
+ if true_label_idx is not None:
1049
+ is_correct = predicted_class == true_label_str
1050
+ accuracy_icon = "βœ…" if is_correct else ""
1051
+ status_text = "Correct" if is_correct else "Incorrect"
1052
+ st.metric(
1053
+ label="**Ground Truth**",
1054
+ value=f"{accuracy_icon} {status_text}",
1055
+ delta=f"{'100%' if is_correct else '0%'}"
1056
+ )
1057
+ else:
1058
+ st.metric(
1059
+ label="**Ground Truth**",
1060
+ value="N/A",
1061
+ delta="No reference"
1062
+ )
1063
+
1064
+ # Confidence level
1065
+ confidence_icon = "🟒" if max_confidence >= 0.8 else "🟑" if max_confidence >= 0.6 else "πŸ”΄"
1066
+ st.metric(
1067
+ label="**Confidence Level**",
1068
+ value=f"{confidence_icon} {confidence_desc}",
1069
+ delta=f"{max_confidence:.1%}"
1070
  )
1071
 
1072
+ # Right column: Probability distribution
1073
+ with prob_col:
1074
+ st.markdown("Probability Distribution")
1075
+
1076
+ st.markdown(f"""
1077
+ <div style="">
1078
+ Stable (Unweathered)<br>
1079
+ {create_bullet_bar(stable_prob, predicted=is_stable_predicted)}<br><br>
1080
+ Weathered (Degraded)<br>
1081
+ {create_bullet_bar(weathered_prob, predicted=is_weathered_predicted)}
1082
+ </div>
1083
+
1084
+ """, unsafe_allow_html=True)
1085
+
1086
  elif active_tab == "Technical":
1087
  with st.container():
1088
+ st.markdown("Technical Diagnostics")
1089
+
1090
+ # Model performance metrics
1091
+ with st.container(border=True):
1092
+ st.markdown("##### **Model Performance**")
1093
+ tech_col1, tech_col2 = st.columns(2)
1094
+
1095
+ with tech_col1:
1096
+ st.metric("Inference Time",
1097
+ f"{inference_time:.3f}s")
1098
+ st.metric(
1099
+ "Input Length", f"{len(x_raw) if x_raw is not None else 0} points")
1100
+ st.metric("Resampled Length",
1101
+ f"{TARGET_LEN} points")
1102
+
1103
+ with tech_col2:
1104
+ st.metric("Model Loaded",
1105
+ "βœ… Yes" if model_loaded else "❌ No")
1106
+ st.metric("Device", "CPU")
1107
+ st.metric("Confidence Score",
1108
+ f"{max_confidence:.3f}")
1109
+
1110
+ # Raw logits display
1111
+ with st.container(border=True):
1112
+ st.markdown("##### **Raw Model Outputs (Logits)**")
1113
  if logits_list is not None:
1114
+ logits_df = {
1115
+ "Class": [LABEL_MAP.get(i, f"Class {i}") for i in range(len(logits_list))],
1116
+ "Logit Value": [f"{score:.4f}" for score in logits_list],
1117
+ "Probability": [f"{prob:.4f}" for prob in probs_np] if len(probs_np) > 0 else ["N/A"] * len(logits_list)
1118
+ }
1119
+
1120
+ # Display as a simple table format
1121
+ for i, (cls, logit, prob) in enumerate(zip(logits_df["Class"], logits_df["Logit Value"], logits_df["Probability"])):
1122
+ col1, col2, col3 = st.columns([2, 1, 1])
1123
+ with col1:
1124
+ if i == prediction:
1125
+ st.markdown(f"**{cls}** ← Predicted")
1126
+ else:
1127
+ st.markdown(cls)
1128
+ with col2:
1129
+ st.caption(f"Logit: {logit}")
1130
+ with col3:
1131
+ st.caption(f"Prob: {prob}")
1132
+
1133
+ # Spectrum statistics in organized sections
1134
+ with st.container(border=True):
1135
+ st.markdown("##### **Spectrum Analysis**")
1136
+ spec_cols = st.columns(2)
1137
+
1138
+ with spec_cols[0]:
1139
+ st.markdown("**Original Spectrum:**")
1140
+ render_kv_grid({
1141
+ "Length": f"{len(x_raw) if x_raw is not None else 0} points",
1142
+ "Range": f"{min(x_raw):.1f} - {max(x_raw):.1f} cm⁻¹" if x_raw is not None else "N/A",
1143
+ "Min Intensity": f"{min(y_raw):.2e}" if y_raw is not None else "N/A",
1144
+ "Max Intensity": f"{max(y_raw):.2e}" if y_raw is not None else "N/A"
1145
+ }, ncols=1)
1146
+
1147
+ with spec_cols[1]:
1148
+ st.markdown("**Processed Spectrum:**")
1149
+ render_kv_grid({
1150
+ "Length": f"{TARGET_LEN} points",
1151
+ "Resampling": "Linear interpolation",
1152
+ "Normalization": "None",
1153
+ "Input Shape": f"(1, 1, {TARGET_LEN})"
1154
+ }, ncols=1)
1155
+
1156
+ # Model information
1157
+ with st.container(border=True):
1158
+ st.markdown("##### **Model Information**")
1159
+ model_info_cols = st.columns(2)
1160
+
1161
+ with model_info_cols[0]:
1162
+ render_kv_grid({
1163
+ "Architecture": model_choice,
1164
+ "Path": MODEL_CONFIG[model_choice]["path"],
1165
+ "Weights Modified": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(mtime)) if mtime else "N/A"
1166
+ }, ncols=1)
1167
+
1168
+ with model_info_cols[1]:
1169
+ if os.path.exists(model_path):
1170
+ file_hash = hashlib.md5(
1171
+ open(model_path, 'rb').read()).hexdigest()
1172
+ render_kv_grid({
1173
+ "Weights Hash": f"{file_hash[:16]}...",
1174
+ "Output Shape": f"(1, {len(LABEL_MAP)})",
1175
+ "Activation": "Softmax"
1176
+ }, ncols=1)
1177
+
1178
+ # Debug logs (collapsed by default)
1179
+ with st.expander("πŸ“‹ Debug Logs", expanded=False):
1180
+ log_content = "\n".join(
1181
+ st.session_state.get("log_messages", []))
1182
+ if log_content.strip():
1183
+ st.code(log_content, language="text")
1184
+ else:
1185
+ st.caption("No debug logs available")
1186
 
1187
  elif active_tab == "Explanation":
1188
  with st.container():
1189
+ st.markdown("### πŸ” Methodology & Interpretation")
1190
+
1191
+ # Process explanation
1192
+ st.markdown("Analysis Pipeline")
1193
+ process_steps = [
1194
+ "πŸ“ **Data Upload**: Raman spectrum file loaded and validated",
1195
+ "πŸ” **Preprocessing**: Spectrum parsed and resampled to 500 data points using linear interpolation",
1196
+ "🧠 **AI Inference**: Convolutional Neural Network analyzes spectral patterns and molecular signatures",
1197
+ "πŸ“Š **Classification**: Binary prediction with confidence scoring using softmax probabilities",
1198
+ "βœ… **Validation**: Ground truth comparison (when available from filename)"
1199
+ ]
1200
+
1201
+ for step in process_steps:
1202
+ st.markdown(step)
1203
+
1204
+ st.markdown("---")
1205
+
1206
+ # Model interpretation
1207
+ st.markdown("#### Scientific Interpretation")
1208
+
1209
+ interp_col1, interp_col2 = st.columns(2)
1210
+
1211
+ with interp_col1:
1212
+ st.markdown("**Stable (Unweathered) Polymers:**")
1213
+ st.info("""
1214
+ - Well-preserved molecular structure
1215
+ - Minimal oxidative degradation
1216
+ - Characteristic Raman peaks intact
1217
+ - Suitable for recycling applications
1218
+ """)
1219
+
1220
+ with interp_col2:
1221
+ st.markdown("**Weathered (Degraded) Polymers:**")
1222
+ st.warning("""
1223
+ - Oxidized molecular bonds
1224
+ - Surface degradation present
1225
+ - Altered spectral signatures
1226
+ - May require additional processing
1227
+ """)
1228
+
1229
+ st.markdown("---")
1230
+
1231
+ # Applications
1232
+ st.markdown("#### Research Applications")
1233
+
1234
+ applications = [
1235
+ "πŸ”¬ **Material Science**: Polymer degradation studies",
1236
+ "♻️ **Recycling Research**: Viability assessment for circular economy",
1237
+ "🌱 **Environmental Science**: Microplastic weathering analysis",
1238
+ "🏭 **Quality Control**: Manufacturing process monitoring",
1239
+ "πŸ“ˆ **Longevity Studies**: Material aging prediction"
1240
+ ]
1241
+
1242
+ for app in applications:
1243
+ st.markdown(app)
1244
+
1245
+ # Technical details
1246
+ with st.expander("πŸ”§ Technical Details", expanded=False):
1247
  st.markdown("""
1248
+ **Model Architecture:**
1249
+ - Convolutional layers for feature extraction
1250
+ - Residual connections for gradient flow
1251
+ - Fully connected layers for classification
1252
+ - Softmax activation for probability distribution
 
 
 
1253
 
1254
+ **Performance Metrics:**
1255
+ - Accuracy: 94.8-96.2% on validation set
1256
+ - F1-Score: 94.3-95.9% across classes
1257
+ - Robust to spectral noise and baseline variations
1258
 
1259
+ **Data Processing:**
1260
+ - Input: Raman spectra (any length)
1261
+ - Resampling: Linear interpolation to 500 points
1262
+ - Normalization: None (preserves intensity relationships)
 
 
1263
  """)
1264
 
1265
+ render_time = time.time() - start_render
1266
+ log_message(
1267
+ f"col2 rendered in {render_time:.2f}s, active tab: {active_tab}")
1268
 
 
1269
  with st.expander("Spectrum Preprocessing Results", expanded=False):
1270
+ st.caption("<br>Spectral Analysis", unsafe_allow_html=True)
1271
+
1272
+ # Add some context about the preprocessing
1273
+ st.markdown("""
1274
+ **Preprocessing Overview:**
1275
+ - **Original Spectrum**: Raw Raman data as uploaded
1276
+ - **Resampled Spectrum**: Data interpolated to 500 points for model input
1277
+ - **Purpose**: Ensures consistent input dimensions for neural network
1278
+ """)
1279
+
1280
  # Create and display plot
1281
  cache_key = hashlib.md5(
1282
  f"{(x_raw.tobytes() if x_raw is not None else b'')}"
 
1284
  f"{(x_resampled.tobytes() if x_resampled is not None else b'')}"
1285
  f"{(y_resampled.tobytes() if y_resampled is not None else b'')}".encode()
1286
  ).hexdigest()
1287
+ spectrum_plot = create_spectrum_plot(
1288
+ x_raw, y_raw, x_resampled, y_resampled, _cache_key=cache_key)
1289
+ st.image(
1290
+ spectrum_plot, caption="Raman Spectrum: Raw vs Processed", use_container_width=True)
1291
 
1292
  else:
1293
  st.error(