VJBharathkumar commited on
Commit
1dc8d47
·
verified ·
1 Parent(s): 2958274

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +217 -158
src/streamlit_app.py CHANGED
@@ -13,12 +13,13 @@ import matplotlib.pyplot as plt
13
 
14
  from fpdf import FPDF
15
 
16
- # -----------------------------
 
17
  # Page config
18
- # -----------------------------
19
  st.set_page_config(
20
  page_title="Pneumonia Detection (Chest X-ray) – Clinical Decision Support",
21
- layout="centered"
22
  )
23
 
24
  st.title("Pneumonia Detection (Chest X-ray) – Clinical Decision Support")
@@ -27,14 +28,13 @@ st.caption(
27
  "This tool is for decision support only and does not replace clinical judgment."
28
  )
29
 
30
- # -----------------------------
 
31
  # Paths / Model Loading
32
- # -----------------------------
33
  REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
  MODEL_PATH = os.path.join(REPO_ROOT, "model.keras")
35
-
36
- # Optional: store a version tag manually in a json file in repo root if you want
37
- VERSION_PATH = os.path.join(REPO_ROOT, "model_version.json")
38
 
39
  @st.cache_resource
40
  def load_model():
@@ -44,20 +44,11 @@ def load_model():
44
  try:
45
  m = keras.models.load_model(MODEL_PATH)
46
  except Exception:
 
47
  keras.config.enable_unsafe_deserialization()
48
  m = keras.models.load_model(MODEL_PATH, safe_mode=False)
49
  return m
50
 
51
- model = load_model()
52
-
53
- # read model input details
54
- input_shape = model.input_shape # (None, H, W, C)
55
- img_size = int(input_shape[1]) if input_shape and input_shape[1] else 256
56
- exp_ch = int(input_shape[-1]) if input_shape and input_shape[-1] else 1
57
-
58
- # -----------------------------
59
- # Utilities
60
- # -----------------------------
61
  def get_model_version():
62
  if os.path.exists(VERSION_PATH):
63
  try:
@@ -68,10 +59,27 @@ def get_model_version():
68
  return "v1"
69
 
70
  MODEL_VERSION = get_model_version()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- def read_dicom(uploaded_file) -> np.ndarray:
73
- data = uploaded_file.read()
74
- dcm = pydicom.dcmread(io.BytesIO(data))
75
  img = dcm.pixel_array.astype(np.float32)
76
 
77
  # Normalize to 0..1
@@ -82,143 +90,184 @@ def read_dicom(uploaded_file) -> np.ndarray:
82
  return img
83
 
84
  def preprocess(img_2d: np.ndarray) -> np.ndarray:
85
- # (H,W) -> (1,H,W,C) float32 0..1
 
 
86
  x = tf.convert_to_tensor(img_2d[..., np.newaxis], dtype=tf.float32) # (H,W,1)
87
  x = tf.image.resize(x, (img_size, img_size))
88
  x = tf.clip_by_value(x, 0.0, 1.0)
89
- x = x.numpy()
90
 
91
  if exp_ch == 3 and x.shape[-1] == 1:
92
  x = np.repeat(x, 3, axis=-1)
93
  elif exp_ch == 1 and x.shape[-1] == 3:
94
  x = x[..., :1]
95
 
96
- x = np.expand_dims(x, axis=0)
97
  return x.astype(np.float32)
98
 
99
  def predict_prob(x: np.ndarray) -> float:
 
 
 
 
100
  pred = model.predict(x, verbose=0)
101
  if isinstance(pred, (list, tuple)):
102
  prob = float(np.ravel(pred[-1])[0])
103
  else:
104
  prob = float(np.ravel(pred)[0])
 
105
  return max(0.0, min(1.0, prob))
106
 
107
- def confidence_bucket(prob: float) -> str:
108
- # Clinical-friendly interpretation (you can adjust the bands)
109
- if prob < 0.30:
110
- return "Low likelihood (< 0.30)"
111
- elif prob <= 0.60:
112
- return "Borderline suspicion (0.30 – 0.60)"
113
- else:
114
- return "High likelihood (> 0.60)"
115
 
116
- # -----------------------------
117
- # Grad-CAM (ResNet-style) helper
118
- # -----------------------------
119
- def find_last_conv_layer(m: keras.Model) -> str:
120
- # picks the last Conv2D layer name
 
 
 
 
 
 
 
 
 
 
 
 
121
  for layer in reversed(m.layers):
122
- if isinstance(layer, keras.layers.Conv2D):
123
  return layer.name
124
- # If model is nested and last conv is inside base model:
125
- for layer in reversed(m.layers):
126
- if isinstance(layer, keras.Model):
127
- for sub in reversed(layer.layers):
128
- if isinstance(sub, keras.layers.Conv2D):
129
- return sub.name
130
- raise ValueError("Could not find a Conv2D layer for Grad-CAM.")
131
 
132
  @st.cache_resource
133
- def get_gradcam_model(m: keras.Model):
134
- last_conv = find_last_conv_layer(m)
135
- conv_layer = m.get_layer(last_conv)
136
- grad_model = keras.Model([m.inputs], [conv_layer.output, m.output])
137
- return grad_model, last_conv
138
 
139
- def make_gradcam_heatmap(x_input: np.ndarray) -> np.ndarray:
140
- grad_model, _ = get_gradcam_model(model)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
- x_tensor = tf.convert_to_tensor(x_input, dtype=tf.float32)
143
  with tf.GradientTape() as tape:
144
- conv_out, preds = grad_model(x_tensor)
145
 
 
146
  if isinstance(preds, (list, tuple)):
147
  preds = preds[-1]
148
 
149
- # binary prob is preds[:,0]
150
- score = preds[:, 0]
 
 
151
 
152
- grads = tape.gradient(score, conv_out)
153
- pooled = tf.reduce_mean(grads, axis=(0, 1, 2))
154
- conv_out = conv_out[0]
155
 
156
- heatmap = conv_out @ pooled[..., tf.newaxis]
157
- heatmap = tf.squeeze(heatmap)
158
 
 
159
  heatmap = tf.maximum(heatmap, 0)
160
- denom = tf.reduce_max(heatmap) + 1e-8
161
- heatmap = heatmap / denom
 
 
162
  return heatmap.numpy()
163
 
164
- def overlay_heatmap_on_image(img_2d: np.ndarray, heatmap: np.ndarray):
165
- # Resize heatmap to img_size
 
 
 
166
  heat = tf.image.resize(heatmap[..., None], (img_size, img_size)).numpy().squeeze()
167
 
168
  fig = plt.figure(figsize=(5, 5))
169
- plt.imshow(img_2d, cmap="gray")
170
  plt.imshow(heat, cmap="jet", alpha=0.35)
171
  plt.axis("off")
172
  plt.tight_layout()
173
  return fig
174
 
175
- # -----------------------------
176
- # PDF generator
177
- # -----------------------------
178
- def build_pdf_report(df: pd.DataFrame, threshold: float) -> bytes:
179
- pdf = FPDF()
 
 
 
 
 
180
  pdf.add_page()
 
 
181
  pdf.set_font("Arial", size=12)
 
 
 
 
 
 
 
182
 
183
- pdf.multi_cell(0, 8, f"Pneumonia Detection Report")
184
- pdf.ln(1)
185
  pdf.set_font("Arial", size=10)
186
- pdf.multi_cell(0, 6, f"Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
187
- pdf.multi_cell(0, 6, f"Model version: {MODEL_VERSION}")
188
- pdf.multi_cell(0, 6, f"Decision threshold used: {threshold:.2f}")
189
- pdf.ln(2)
190
-
191
- # Table header
192
- pdf.set_font("Arial", "B", 9)
193
- headers = ["file_name", "probability", "prediction", "confidence_band"]
194
- col_w = [70, 25, 35, 55]
195
- for h, w in zip(headers, col_w):
196
- pdf.cell(w, 7, h, border=1)
197
- pdf.ln()
198
-
199
- # Rows
200
- pdf.set_font("Arial", size=9)
201
- for _, r in df.iterrows():
202
- pdf.cell(col_w[0], 7, str(r["file_name"])[:40], border=1)
203
- pdf.cell(col_w[1], 7, f'{float(r["probability"]):.4f}', border=1)
204
- pdf.cell(col_w[2], 7, str(r["prediction"])[:18], border=1)
205
- pdf.cell(col_w[3], 7, str(r["confidence_band"])[:30], border=1)
206
- pdf.ln()
207
 
208
  return pdf.output(dest="S").encode("latin-1")
209
 
210
- # -----------------------------
 
211
  # UI
212
- # -----------------------------
213
  st.subheader("Model Parameters")
214
 
215
  threshold = st.slider(
216
  "Decision Threshold",
217
  min_value=0.01,
218
  max_value=0.99,
219
- value=0.37, # your ResNet best-thr default
220
  step=0.01,
221
- help="If predicted probability ≥ threshold → Pneumonia, else → Not Pneumonia."
222
  )
223
 
224
  show_gradcam = st.checkbox("Show Grad-CAM heatmap (explainability)", value=True)
@@ -228,7 +277,7 @@ st.subheader("Upload Chest X-ray DICOM Files")
228
  uploaded_files = st.file_uploader(
229
  "Select one or multiple DICOM files (.dcm)",
230
  type=["dcm"],
231
- accept_multiple_files=True
232
  )
233
 
234
  col1, col2 = st.columns(2)
@@ -242,38 +291,54 @@ if clear:
242
 
243
  st.subheader("Prediction Results")
244
 
 
 
 
 
245
  if submit:
246
  if not uploaded_files:
247
  st.warning("Please upload at least one DICOM file before submitting.")
248
  else:
249
  rows = []
 
 
250
  with st.spinner("Running inference..."):
251
  for f in uploaded_files:
 
252
  try:
253
- img = read_dicom(f)
 
254
  x = preprocess(img)
255
  prob = predict_prob(x)
 
256
  pred_label = "Pneumonia" if prob >= threshold else "Not Pneumonia"
257
- band = confidence_bucket(prob)
258
 
259
- rows.append({
260
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
261
- "model_version": MODEL_VERSION,
262
- "file_name": f.name,
263
- "probability": prob,
264
- "prediction": pred_label,
265
- "confidence_band": band
266
- })
 
 
 
 
267
 
268
  except Exception as e:
269
- rows.append({
270
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
271
- "model_version": MODEL_VERSION,
272
- "file_name": f.name,
273
- "probability": np.nan,
274
- "prediction": "Error",
275
- "confidence_band": str(e)
276
- })
 
 
 
277
 
278
  df = pd.DataFrame(rows)
279
 
@@ -282,66 +347,60 @@ if submit:
282
  if r["prediction"] == "Error":
283
  st.error(
284
  f"For the uploaded file '{r['file_name']}', the system could not generate a prediction. "
285
- f"Reason: {r['confidence_band']}."
 
 
 
 
 
 
 
 
286
  )
287
- continue
288
-
289
- prob_pct = float(r["probability"]) * 100.0
290
- st.write(
291
- f"For the uploaded file '{r['file_name']}', the model estimates a pneumonia probability of "
292
- f"{prob_pct:.2f}%. This falls under '{r['confidence_band']}'. "
293
- f"Based on the selected decision threshold of {threshold:.2f}, the predicted outcome is "
294
- f"'{r['prediction']}'."
295
- )
296
-
297
- if show_gradcam:
298
- try:
299
- # Use original image for display; heatmap computed from resized input
300
- heatmap = make_gradcam_heatmap(preprocess(read_dicom(next(ff for ff in uploaded_files if ff.name == r["file_name"]))))
301
-
302
- # We need original image again (Streamlit upload read pointer consumed; re-read by caching bytes)
303
- # Workaround: store bytes during first loop is better; for simplicity, skip re-read failure.
304
- except Exception:
305
- pass
306
 
307
- # Show Grad-CAM images in a robust way (re-read bytes by caching)
308
  if show_gradcam:
309
  st.markdown("### Grad-CAM Heatmaps")
310
- for f in uploaded_files:
311
- try:
312
- # read again safely (need cached bytes)
313
- data = f.getvalue()
314
- dcm = pydicom.dcmread(io.BytesIO(data))
315
- img = dcm.pixel_array.astype(np.float32)
316
- img = (img - img.min()) / (img.max() - img.min() + 1e-8)
317
 
318
- x = preprocess(img)
 
 
 
 
319
  heatmap = make_gradcam_heatmap(x)
320
- fig = overlay_heatmap_on_image(tf.image.resize(img[..., None], (img_size, img_size)).numpy().squeeze(), heatmap)
321
- st.write(f"Heatmap for: {f.name}")
322
  st.pyplot(fig)
323
  except Exception as e:
324
- st.warning(f"Could not generate Grad-CAM for {f.name}. Reason: {e}")
 
 
325
 
326
  # Downloads
327
  st.markdown("### Downloads")
 
328
  csv_bytes = df.to_csv(index=False).encode("utf-8")
329
  st.download_button(
330
  "Download CSV",
331
  data=csv_bytes,
332
  file_name="predictions.csv",
333
  mime="text/csv",
334
- use_container_width=True
335
  )
336
 
337
- pdf_bytes = build_pdf_report(df[df["prediction"] != "Error"], threshold)
338
- st.download_button(
339
- "Download PDF Report",
340
- data=pdf_bytes,
341
- file_name="pneumonia_report.pdf",
342
- mime="application/pdf",
343
- use_container_width=True
344
- )
 
 
 
 
 
345
 
346
  st.divider()
347
  st.caption(
 
13
 
14
  from fpdf import FPDF
15
 
16
+
17
+ # ============================================================
18
  # Page config
19
+ # ============================================================
20
  st.set_page_config(
21
  page_title="Pneumonia Detection (Chest X-ray) – Clinical Decision Support",
22
+ layout="centered",
23
  )
24
 
25
  st.title("Pneumonia Detection (Chest X-ray) – Clinical Decision Support")
 
28
  "This tool is for decision support only and does not replace clinical judgment."
29
  )
30
 
31
+
32
+ # ============================================================
33
  # Paths / Model Loading
34
+ # ============================================================
35
  REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
36
  MODEL_PATH = os.path.join(REPO_ROOT, "model.keras")
37
+ VERSION_PATH = os.path.join(REPO_ROOT, "model_version.json") # optional
 
 
38
 
39
  @st.cache_resource
40
  def load_model():
 
44
  try:
45
  m = keras.models.load_model(MODEL_PATH)
46
  except Exception:
47
+ # if Lambda layers / unsafe deserialization exists
48
  keras.config.enable_unsafe_deserialization()
49
  m = keras.models.load_model(MODEL_PATH, safe_mode=False)
50
  return m
51
 
 
 
 
 
 
 
 
 
 
 
52
  def get_model_version():
53
  if os.path.exists(VERSION_PATH):
54
  try:
 
59
  return "v1"
60
 
61
  MODEL_VERSION = get_model_version()
62
+ model = load_model()
63
+
64
+ # model input details: (None, H, W, C)
65
+ input_shape = model.input_shape
66
+ img_size = int(input_shape[1]) if input_shape and input_shape[1] else 256
67
+ exp_ch = int(input_shape[-1]) if input_shape and input_shape[-1] else 1
68
+
69
+
70
+ # ============================================================
71
+ # Helpers
72
+ # ============================================================
73
+ def interpret_confidence(prob: float) -> str:
74
+ if prob < 0.30:
75
+ return "Low likelihood (<30%)"
76
+ elif prob <= 0.60:
77
+ return "Borderline suspicion (30–60%)"
78
+ else:
79
+ return "High likelihood (>60%)"
80
 
81
+ def read_dicom_bytes(file_bytes: bytes) -> np.ndarray:
82
+ dcm = pydicom.dcmread(io.BytesIO(file_bytes))
 
83
  img = dcm.pixel_array.astype(np.float32)
84
 
85
  # Normalize to 0..1
 
90
  return img
91
 
92
  def preprocess(img_2d: np.ndarray) -> np.ndarray:
93
+ """
94
+ (H,W) -> (1,img_size,img_size,C) float32 in 0..1
95
+ """
96
  x = tf.convert_to_tensor(img_2d[..., np.newaxis], dtype=tf.float32) # (H,W,1)
97
  x = tf.image.resize(x, (img_size, img_size))
98
  x = tf.clip_by_value(x, 0.0, 1.0)
99
+ x = x.numpy() # (img_size,img_size,1)
100
 
101
  if exp_ch == 3 and x.shape[-1] == 1:
102
  x = np.repeat(x, 3, axis=-1)
103
  elif exp_ch == 1 and x.shape[-1] == 3:
104
  x = x[..., :1]
105
 
106
+ x = np.expand_dims(x, axis=0) # (1,img_size,img_size,C)
107
  return x.astype(np.float32)
108
 
109
  def predict_prob(x: np.ndarray) -> float:
110
+ """
111
+ Supports single-head and multi-head models.
112
+ Uses last output as probability when outputs are list/tuple.
113
+ """
114
  pred = model.predict(x, verbose=0)
115
  if isinstance(pred, (list, tuple)):
116
  prob = float(np.ravel(pred[-1])[0])
117
  else:
118
  prob = float(np.ravel(pred)[0])
119
+
120
  return max(0.0, min(1.0, prob))
121
 
 
 
 
 
 
 
 
 
122
 
123
+ # ============================================================
124
+ # Grad-CAM (robust layer selection)
125
+ # ============================================================
126
+ def _find_last_conv2d_layer_name(m: keras.Model) -> str:
127
+ # Prefer backbone conv layers if a common backbone layer exists
128
+ backbone_names = ["resnet50", "ResNet50", "backbone"]
129
+ for nm in backbone_names:
130
+ try:
131
+ bb = m.get_layer(nm)
132
+ # walk backwards in backbone
133
+ for layer in reversed(bb.layers):
134
+ if isinstance(layer, tf.keras.layers.Conv2D):
135
+ return f"{nm}/{layer.name}"
136
+ except Exception:
137
+ pass
138
+
139
+ # Fallback: scan the whole model
140
  for layer in reversed(m.layers):
141
+ if isinstance(layer, tf.keras.layers.Conv2D):
142
  return layer.name
143
+
144
+ raise ValueError("No Conv2D layer found for Grad-CAM.")
 
 
 
 
 
145
 
146
  @st.cache_resource
147
+ def get_gradcam_model_and_layername():
148
+ last_conv_name = _find_last_conv2d_layer_name(model)
 
 
 
149
 
150
+ # If name includes "backbone/layer", resolve it
151
+ if "/" in last_conv_name:
152
+ parent, child = last_conv_name.split("/", 1)
153
+ conv_layer = model.get_layer(parent).get_layer(child)
154
+ else:
155
+ conv_layer = model.get_layer(last_conv_name)
156
+
157
+ grad_model = keras.Model(
158
+ inputs=model.inputs,
159
+ outputs=[conv_layer.output, model.output],
160
+ )
161
+ return grad_model, last_conv_name
162
+
163
+ def make_gradcam_heatmap(img_array: np.ndarray) -> np.ndarray:
164
+ """
165
+ img_array: (1,H,W,C)
166
+ returns heatmap: (Hc, Wc) normalized 0..1
167
+ """
168
+ grad_model, _ = get_gradcam_model_and_layername()
169
 
 
170
  with tf.GradientTape() as tape:
171
+ conv_outputs, preds = grad_model(img_array)
172
 
173
+ # multi-head -> take last output for probability
174
  if isinstance(preds, (list, tuple)):
175
  preds = preds[-1]
176
 
177
+ # preds shape could be (1,1) or (1,)
178
+ loss = preds[:, 0] if preds.ndim == 2 else preds
179
+
180
+ grads = tape.gradient(loss, conv_outputs)
181
 
182
+ # safety in case grads is None
183
+ if grads is None:
184
+ raise ValueError("Gradients are None. Grad-CAM cannot be computed for this model output.")
185
 
186
+ pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2)) # (channels,)
187
+ conv_outputs = conv_outputs[0] # (Hc,Wc,channels)
188
 
189
+ heatmap = tf.reduce_sum(conv_outputs * pooled_grads, axis=-1) # (Hc,Wc)
190
  heatmap = tf.maximum(heatmap, 0)
191
+
192
+ denom = tf.reduce_max(heatmap)
193
+ heatmap = heatmap / (denom + 1e-8)
194
+
195
  return heatmap.numpy()
196
 
197
+ def overlay_heatmap_on_image(img_2d_resized: np.ndarray, heatmap: np.ndarray):
198
+ """
199
+ img_2d_resized: (img_size,img_size) in 0..1
200
+ heatmap: (Hc,Wc) in 0..1
201
+ """
202
  heat = tf.image.resize(heatmap[..., None], (img_size, img_size)).numpy().squeeze()
203
 
204
  fig = plt.figure(figsize=(5, 5))
205
+ plt.imshow(img_2d_resized, cmap="gray")
206
  plt.imshow(heat, cmap="jet", alpha=0.35)
207
  plt.axis("off")
208
  plt.tight_layout()
209
  return fig
210
 
211
+
212
+ # ============================================================
213
+ # PDF generator (stable)
214
+ # ============================================================
215
+ class PDF(FPDF):
216
+ pass
217
+
218
+ def build_pdf_report(df: pd.DataFrame, threshold: float, model_version: str) -> bytes:
219
+ pdf = PDF()
220
+ pdf.set_auto_page_break(auto=True, margin=12)
221
  pdf.add_page()
222
+ pdf.set_margins(12, 12, 12)
223
+
224
  pdf.set_font("Arial", size=12)
225
+ pdf.cell(0, 8, "Pneumonia Detection Report", ln=True)
226
+
227
+ pdf.set_font("Arial", size=10)
228
+ pdf.cell(0, 7, f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ln=True)
229
+ pdf.cell(0, 7, f"Model Version: {model_version}", ln=True)
230
+ pdf.cell(0, 7, f"Decision Threshold: {threshold:.2f}", ln=True)
231
+ pdf.ln(3)
232
 
 
 
233
  pdf.set_font("Arial", size=10)
234
+
235
+ # Write each row safely
236
+ for _, row in df.iterrows():
237
+ pdf.set_x(pdf.l_margin)
238
+ prob = row.get("probability", np.nan)
239
+ prob_pct = "NA" if pd.isna(prob) else f"{float(prob)*100:.2f}%"
240
+
241
+ lines = [
242
+ f"File: {row.get('file_name','')}",
243
+ f"Probability: {prob_pct}",
244
+ f"Confidence Level: {row.get('confidence_level','')}",
245
+ f"Prediction: {row.get('prediction','')}",
246
+ f"Timestamp: {row.get('timestamp','')}",
247
+ ]
248
+
249
+ for line in lines:
250
+ # Use multi_cell for wrapping and reset x each time
251
+ pdf.set_x(pdf.l_margin)
252
+ pdf.multi_cell(0, 6, line)
253
+
254
+ pdf.ln(2)
255
 
256
  return pdf.output(dest="S").encode("latin-1")
257
 
258
+
259
+ # ============================================================
260
  # UI
261
+ # ============================================================
262
  st.subheader("Model Parameters")
263
 
264
  threshold = st.slider(
265
  "Decision Threshold",
266
  min_value=0.01,
267
  max_value=0.99,
268
+ value=0.37, # ResNet default (your best thr)
269
  step=0.01,
270
+ help="If predicted probability ≥ threshold → Pneumonia, else → Not Pneumonia.",
271
  )
272
 
273
  show_gradcam = st.checkbox("Show Grad-CAM heatmap (explainability)", value=True)
 
277
  uploaded_files = st.file_uploader(
278
  "Select one or multiple DICOM files (.dcm)",
279
  type=["dcm"],
280
+ accept_multiple_files=True,
281
  )
282
 
283
  col1, col2 = st.columns(2)
 
291
 
292
  st.subheader("Prediction Results")
293
 
294
+
295
+ # ============================================================
296
+ # Inference
297
+ # ============================================================
298
  if submit:
299
  if not uploaded_files:
300
  st.warning("Please upload at least one DICOM file before submitting.")
301
  else:
302
  rows = []
303
+ file_cache = [] # (filename, bytes, img_2d_norm)
304
+
305
  with st.spinner("Running inference..."):
306
  for f in uploaded_files:
307
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
308
  try:
309
+ b = f.getvalue()
310
+ img = read_dicom_bytes(b) # (H,W) 0..1
311
  x = preprocess(img)
312
  prob = predict_prob(x)
313
+
314
  pred_label = "Pneumonia" if prob >= threshold else "Not Pneumonia"
315
+ conf_level = interpret_confidence(prob)
316
 
317
+ rows.append(
318
+ {
319
+ "timestamp": ts,
320
+ "model_version": MODEL_VERSION,
321
+ "file_name": f.name,
322
+ "probability": prob,
323
+ "confidence_level": conf_level,
324
+ "prediction": pred_label,
325
+ }
326
+ )
327
+
328
+ file_cache.append((f.name, b, img))
329
 
330
  except Exception as e:
331
+ rows.append(
332
+ {
333
+ "timestamp": ts,
334
+ "model_version": MODEL_VERSION,
335
+ "file_name": f.name,
336
+ "probability": np.nan,
337
+ "confidence_level": "NA",
338
+ "prediction": "Error",
339
+ "error": str(e),
340
+ }
341
+ )
342
 
343
  df = pd.DataFrame(rows)
344
 
 
347
  if r["prediction"] == "Error":
348
  st.error(
349
  f"For the uploaded file '{r['file_name']}', the system could not generate a prediction. "
350
+ f"Reason: {r.get('error','Unknown error')}."
351
+ )
352
+ else:
353
+ prob_pct = float(r["probability"]) * 100.0
354
+ st.write(
355
+ f"For the uploaded file '{r['file_name']}', the model estimates a pneumonia probability of "
356
+ f"{prob_pct:.2f}%. This falls under '{r['confidence_level']}'. "
357
+ f"Based on the selected decision threshold of {threshold:.2f}, the predicted outcome is "
358
+ f"'{r['prediction']}'."
359
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
+ # Grad-CAM section
362
  if show_gradcam:
363
  st.markdown("### Grad-CAM Heatmaps")
 
 
 
 
 
 
 
364
 
365
+ # Resize original image for display
366
+ for (fname, _, img_2d) in file_cache:
367
+ try:
368
+ img_resized = tf.image.resize(img_2d[..., None], (img_size, img_size)).numpy().squeeze()
369
+ x = preprocess(img_2d)
370
  heatmap = make_gradcam_heatmap(x)
371
+ fig = overlay_heatmap_on_image(img_resized, heatmap)
372
+ st.write(f"Heatmap for: {fname}")
373
  st.pyplot(fig)
374
  except Exception as e:
375
+ # This will now show the *actual* layer list + reason,
376
+ # instead of failing with a wrong hard-coded layer name.
377
+ st.warning(f"Could not generate Grad-CAM for {fname}. Reason: {e}")
378
 
379
  # Downloads
380
  st.markdown("### Downloads")
381
+
382
  csv_bytes = df.to_csv(index=False).encode("utf-8")
383
  st.download_button(
384
  "Download CSV",
385
  data=csv_bytes,
386
  file_name="predictions.csv",
387
  mime="text/csv",
388
+ use_container_width=True,
389
  )
390
 
391
+ df_ok = df[df["prediction"] != "Error"].copy()
392
+ if len(df_ok) > 0:
393
+ pdf_bytes = build_pdf_report(df_ok, threshold, MODEL_VERSION)
394
+ st.download_button(
395
+ "Download PDF Report",
396
+ data=pdf_bytes,
397
+ file_name="pneumonia_report.pdf",
398
+ mime="application/pdf",
399
+ use_container_width=True,
400
+ )
401
+ else:
402
+ st.info("PDF report is available once at least one file is successfully processed.")
403
+
404
 
405
  st.divider()
406
  st.caption(