skarugu commited on
Commit
5d7ad5c
Β·
1 Parent(s): 7fca4c6

Updated streamlit_app_v3.py with new version

Browse files
Files changed (1) hide show
  1. srcstreamlit_app_v3.py +902 -0
srcstreamlit_app_v3.py ADDED
@@ -0,0 +1,902 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/streamlit_app.py
2
+ """
3
+ MyoSight β€” Myotube & Nuclei Analyser
4
+ ========================================
5
+ Drop-in replacement for streamlit_app.py on Hugging Face Spaces.
6
+
7
+ New features vs the original Myotube Analyzer V2:
8
+ ✦ Animated count-up metrics (9 counters)
9
+ ✦ Instance overlay β€” nucleus IDs (1,2,3…) + myotube IDs (M1,M2…)
10
+ ✦ Watershed nuclei splitting for accurate counts
11
+ ✦ Myotube surface area (total, mean, max ¡m²) + per-tube bar chart
12
+ ✦ Active learning β€” upload corrected masks β†’ saved to corrections/
13
+ ✦ Low-confidence auto-flagging β†’ image queued for retraining
14
+ ✦ Retraining queue status panel
15
+ ✦ All original sidebar controls preserved
16
+ """
17
+
18
+ import io
19
+ import os
20
+ import json
21
+ import time
22
+ import zipfile
23
+ import hashlib
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ from PIL import Image
30
+
31
+ import streamlit as st
32
+ import torch
33
+ import torch.nn as nn
34
+ import matplotlib
35
+ matplotlib.use("Agg")
36
+ import matplotlib.pyplot as plt
37
+ import matplotlib.patches as mpatches
38
+ from huggingface_hub import hf_hub_download
39
+
40
+ import scipy.ndimage as ndi
41
+ from skimage.morphology import remove_small_objects, disk, closing, opening
42
+ from skimage import measure
43
+ from skimage.segmentation import watershed
44
+ from skimage.feature import peak_local_max
45
+
46
+
47
+ # ─────────────────────────────────────────────────────────────────────────────
48
+ # CONFIG ← edit these two lines to match your HF model repo
49
+ # ─────────────────────────────────────────────────────────────────────────────
50
+ MODEL_REPO_ID = "skarugu/myotube-unet"
51
+ MODEL_FILENAME = "model_final.pt"
52
+
53
+ CONF_FLAG_THR = 0.60 # images below this confidence are queued for retraining
54
+ QUEUE_DIR = Path("retrain_queue")
55
+ CORRECTIONS_DIR = Path("corrections")
56
+
57
+
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+ # Helpers (identical to originals so nothing breaks)
60
+ # ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ def sha256_file(path: str) -> str:
63
+ h = hashlib.sha256()
64
+ with open(path, "rb") as f:
65
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
66
+ h.update(chunk)
67
+ return h.hexdigest()
68
+
69
+
70
+ def png_bytes(arr_u8: np.ndarray) -> bytes:
71
+ buf = io.BytesIO()
72
+ Image.fromarray(arr_u8).save(buf, format="PNG")
73
+ return buf.getvalue()
74
+
75
+
76
+ def resize_u8_to_float01(ch_u8: np.ndarray, W: int, H: int,
77
+ resample=Image.BILINEAR) -> np.ndarray:
78
+ im = Image.fromarray(ch_u8, mode="L").resize((W, H), resample=resample)
79
+ return np.array(im, dtype=np.float32) / 255.0
80
+
81
+
82
+ def get_channel(rgb_u8: np.ndarray, source: str) -> np.ndarray:
83
+ if source == "Red": return rgb_u8[..., 0]
84
+ if source == "Green": return rgb_u8[..., 1]
85
+ if source == "Blue": return rgb_u8[..., 2]
86
+ return (0.299*rgb_u8[...,0] + 0.587*rgb_u8[...,1] + 0.114*rgb_u8[...,2]).astype(np.uint8)
87
+
88
+
89
+ def hex_to_rgb(h: str):
90
+ h = h.lstrip("#")
91
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
92
+
93
+
94
+ # ─────────────────────────────────────────────────────────────────────────────
95
+ # Postprocessing
96
+ # ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ def postprocess_masks(nuc_mask, myo_mask,
99
+ min_nuc_area=20, min_myo_area=500,
100
+ nuc_close_radius=2, myo_close_radius=3):
101
+ """
102
+ Clean up raw predicted masks.
103
+ Nuclei: optional closing to fill gaps, then remove small objects.
104
+ Myotubes: closing + opening to smooth edges, then remove small objects.
105
+ """
106
+ # Nuclei
107
+ nuc_bin = nuc_mask.astype(bool)
108
+ if int(nuc_close_radius) > 0:
109
+ nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
110
+ nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
111
+
112
+ # Myotubes
113
+ selem = disk(int(myo_close_radius))
114
+ myo_bin = closing(myo_mask.astype(bool), selem)
115
+ myo_bin = opening(myo_bin, selem)
116
+ myo_clean = remove_small_objects(myo_bin, min_size=int(min_myo_area)).astype(np.uint8)
117
+
118
+ return nuc_clean, myo_clean
119
+
120
+
121
+ def label_cc(mask: np.ndarray) -> np.ndarray:
122
+ lab, _ = ndi.label(mask.astype(np.uint8))
123
+ return lab
124
+
125
+
126
+ def label_nuclei_watershed(nuc_bin: np.ndarray,
127
+ min_distance: int = 3,
128
+ min_nuc_area: int = 6) -> np.ndarray:
129
+ """Split touching nuclei via distance-transform watershed."""
130
+ nuc_bin = remove_small_objects(nuc_bin.astype(bool), min_size=min_nuc_area)
131
+ if nuc_bin.sum() == 0:
132
+ return np.zeros_like(nuc_bin, dtype=np.int32)
133
+
134
+ dist = ndi.distance_transform_edt(nuc_bin)
135
+ coords = peak_local_max(dist, labels=nuc_bin,
136
+ min_distance=min_distance, exclude_border=False)
137
+ markers = np.zeros_like(nuc_bin, dtype=np.int32)
138
+ for i, (r, c) in enumerate(coords, start=1):
139
+ markers[r, c] = i
140
+
141
+ if markers.max() == 0:
142
+ return ndi.label(nuc_bin.astype(np.uint8))[0].astype(np.int32)
143
+
144
+ return watershed(-dist, markers, mask=nuc_bin).astype(np.int32)
145
+
146
+
147
+ # ─────────────────────────────────────────────────────────────────────────────
148
+ # Surface area (new)
149
+ # ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ def compute_surface_area(myo_mask: np.ndarray, px_um: float = 1.0) -> dict:
152
+ lab = label_cc(myo_mask)
153
+ px_area = px_um ** 2
154
+ per = [round(prop.area * px_area, 2) for prop in measure.regionprops(lab)]
155
+ return {
156
+ "total_area_um2" : round(sum(per), 2),
157
+ "mean_area_um2" : round(float(np.mean(per)) if per else 0.0, 2),
158
+ "max_area_um2" : round(float(np.max(per)) if per else 0.0, 2),
159
+ "per_myotube_areas" : per,
160
+ }
161
+
162
+
163
+ # ─────────────────────────────────────────────────────────────────────────────
164
+ # Biological metrics (counting + fusion + surface area)
165
+ # ─────────────────────────────────────────────────────────────────────────────
166
+
167
+ def compute_bio_metrics(nuc_mask, myo_mask,
168
+ min_overlap_frac=0.1,
169
+ nuc_ws_min_distance=3,
170
+ nuc_ws_min_area=6,
171
+ px_um=1.0) -> dict:
172
+ nuc_lab = label_nuclei_watershed(nuc_mask,
173
+ min_distance=nuc_ws_min_distance,
174
+ min_nuc_area=nuc_ws_min_area)
175
+ myo_lab = label_cc(myo_mask)
176
+ total = int(nuc_lab.max())
177
+
178
+ pos, nm = 0, {}
179
+ for prop in measure.regionprops(nuc_lab):
180
+ coords = prop.coords
181
+ ids = myo_lab[coords[:, 0], coords[:, 1]]
182
+ ids = ids[ids > 0]
183
+ if ids.size == 0:
184
+ continue
185
+ unique, counts = np.unique(ids, return_counts=True)
186
+ mt = int(unique[np.argmax(counts)])
187
+ frac = counts.max() / len(coords)
188
+ if frac >= min_overlap_frac:
189
+ pos += 1
190
+ nm.setdefault(mt, []).append(prop.label)
191
+
192
+ per = [len(v) for v in nm.values()]
193
+ fused = sum(n for n in per if n >= 2)
194
+ fi = 100.0 * fused / total if total else 0.0
195
+ pct = 100.0 * pos / total if total else 0.0
196
+ avg = float(np.mean(per)) if per else 0.0
197
+
198
+ sa = compute_surface_area(myo_mask, px_um=px_um)
199
+
200
+ return {
201
+ "total_nuclei" : total,
202
+ "myHC_positive_nuclei" : int(pos),
203
+ "myHC_positive_percentage" : round(pct, 2),
204
+ "nuclei_fused" : int(fused),
205
+ "myotube_count" : int(len(per)),
206
+ "avg_nuclei_per_myotube" : round(avg, 2),
207
+ "fusion_index" : round(fi, 2),
208
+ "total_area_um2" : sa["total_area_um2"],
209
+ "mean_area_um2" : sa["mean_area_um2"],
210
+ "max_area_um2" : sa["max_area_um2"],
211
+ "_per_myotube_areas" : sa["per_myotube_areas"], # _ prefix = kept out of CSV
212
+ }
213
+
214
+
215
+ # ─────────────────────────────────────────────────────────────────────────────
216
+ # Overlay helpers
217
+ # ─────────────────────────────────────────────────────────────────────────────
218
+
219
+ def make_simple_overlay(rgb_u8, nuc_mask, myo_mask, nuc_color, myo_color, alpha):
220
+ """Flat colour overlay β€” used for the ZIP export (fast, no matplotlib)."""
221
+ base = rgb_u8.astype(np.float32)
222
+ H0, W0 = rgb_u8.shape[:2]
223
+ nuc = np.array(Image.fromarray((nuc_mask*255).astype(np.uint8))
224
+ .resize((W0, H0), Image.NEAREST)) > 0
225
+ myo = np.array(Image.fromarray((myo_mask*255).astype(np.uint8))
226
+ .resize((W0, H0), Image.NEAREST)) > 0
227
+ out = base.copy()
228
+ for mask, color in [(myo, myo_color), (nuc, nuc_color)]:
229
+ c = np.array(color, dtype=np.float32)
230
+ out[mask] = (1 - alpha) * out[mask] + alpha * c
231
+ return np.clip(out, 0, 255).astype(np.uint8)
232
+
233
+
234
+ def make_instance_overlay(rgb_u8: np.ndarray,
235
+ nuc_lab: np.ndarray,
236
+ myo_lab: np.ndarray,
237
+ alpha: float = 0.45,
238
+ label_nuclei: bool = True,
239
+ label_myotubes: bool = True) -> np.ndarray:
240
+ """
241
+ Per-instance coloured overlay rendered at high DPI so labels stay sharp
242
+ when the image is zoomed in.
243
+
244
+ Nuclei β†’ cool colourmap, white numeric IDs on solid dark-blue backing.
245
+ Myotubes β†’ autumn colourmap, white M1/M2… IDs on solid dark-red backing.
246
+
247
+ Font sizes are fixed in data-space pixels so they look the same regardless
248
+ of image resolution. Myotube labels are always 3Γ— bigger than nucleus
249
+ labels so the two tiers are visually distinct at any zoom level.
250
+ """
251
+ orig_h, orig_w = rgb_u8.shape[:2]
252
+ nuc_cmap = plt.cm.get_cmap("cool")
253
+ myo_cmap = plt.cm.get_cmap("autumn")
254
+
255
+ # ── resize label maps to original image resolution ───────────────────────
256
+ def _resize_lab(lab, h, w):
257
+ return np.array(
258
+ Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
259
+ )
260
+
261
+ nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
262
+ myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
263
+ n_nuc = int(nuc_disp.max())
264
+ n_myo = int(myo_disp.max())
265
+
266
+ # ── colour the mask regions ───────────────────────────────────────────────
267
+ base = rgb_u8.astype(np.float32).copy()
268
+ if n_myo > 0:
269
+ myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
270
+ myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
271
+ mask = myo_disp > 0
272
+ base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
273
+ if n_nuc > 0:
274
+ nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
275
+ nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
276
+ mask = nuc_disp > 0
277
+ base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
278
+ overlay = np.clip(base, 0, 255).astype(np.uint8)
279
+
280
+ # ── render at high DPI so the PNG is sharp when zoomed ───────────────────
281
+ # We render the figure at the ORIGINAL pixel size Γ— a scale factor,
282
+ # then downsample back β€” this keeps labels crisp at zoom.
283
+ RENDER_SCALE = 2 # render at 2Γ— then downsample β†’ no blur
284
+ dpi = 150
285
+ fig_w = orig_w * RENDER_SCALE / dpi
286
+ fig_h = orig_h * RENDER_SCALE / dpi
287
+
288
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi)
289
+ ax.imshow(overlay)
290
+ ax.set_xlim(0, orig_w)
291
+ ax.set_ylim(orig_h, 0)
292
+ ax.axis("off")
293
+
294
+ # ── font sizes: fixed in figure points, independent of image size ────────
295
+ # At RENDER_SCALE=2, dpi=150: 1 data pixel β‰ˆ 1/75 inch.
296
+ # We want nucleus labels ~8–10 pt and myotube labels ~18–22 pt.
297
+ font_nuc = 9 # pt β€” clearly readable when zoomed, not overwhelming at full view
298
+ font_myo = 20 # pt β€” dominant, impossible to miss
299
+
300
+ # ── nucleus labels ────────────────────────────────────────────────────────
301
+ if label_nuclei:
302
+ for prop in measure.regionprops(nuc_lab):
303
+ r, c = prop.centroid
304
+ # scale centroid from prediction-space to display-space
305
+ cx = c * (orig_w / nuc_lab.shape[1])
306
+ cy = r * (orig_h / nuc_lab.shape[0])
307
+ ax.text(
308
+ cx, cy, str(prop.label),
309
+ fontsize=font_nuc,
310
+ color="white",
311
+ ha="center", va="center",
312
+ fontweight="bold",
313
+ bbox=dict(
314
+ boxstyle="round,pad=0.25",
315
+ fc="#003366", # solid dark-blue β€” fully opaque
316
+ ec="none",
317
+ alpha=0.92,
318
+ ),
319
+ zorder=2,
320
+ )
321
+
322
+ # ── myotube labels ────────────────────────────────────────────────────────
323
+ if label_myotubes:
324
+ for prop in measure.regionprops(myo_lab):
325
+ r, c = prop.centroid
326
+ cx = c * (orig_w / myo_lab.shape[1])
327
+ cy = r * (orig_h / myo_lab.shape[0])
328
+ ax.text(
329
+ cx, cy, f"M{prop.label}",
330
+ fontsize=font_myo,
331
+ color="white",
332
+ ha="center", va="center",
333
+ fontweight="bold",
334
+ bbox=dict(
335
+ boxstyle="round,pad=0.35",
336
+ fc="#8B0000", # solid dark-red β€” fully opaque
337
+ ec="#FF6666", # thin bright-red border so it pops
338
+ linewidth=1.5,
339
+ alpha=0.95,
340
+ ),
341
+ zorder=3,
342
+ )
343
+
344
+ # ── legend ────────────────────────────────────────────────────────────────
345
+ patches = [
346
+ mpatches.Patch(color=nuc_cmap(0.7), label=f"Nuclei (n={n_nuc})"),
347
+ mpatches.Patch(color=myo_cmap(0.7), label=f"Myotubes (n={n_myo})"),
348
+ ]
349
+ ax.legend(
350
+ handles=patches,
351
+ loc="upper right",
352
+ fontsize=13,
353
+ framealpha=0.85,
354
+ facecolor="#111111",
355
+ labelcolor="white",
356
+ edgecolor="#444444",
357
+ )
358
+
359
+ fig.tight_layout(pad=0)
360
+ buf = io.BytesIO()
361
+ # Save at same high DPI β€” this is what makes the PNG sharp when zoomed
362
+ fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0, dpi=dpi)
363
+ plt.close(fig)
364
+ buf.seek(0)
365
+ return np.array(Image.open(buf).convert("RGB"))
366
+
367
+
368
+ # ─────────────────────────────────────────────────────────────────────────────
369
+ # Animated counter
370
+ # ─────────────────────────────────────────────────────────────────────────────
371
+
372
+ def animated_metric(placeholder, label: str, final_val,
373
+ color: str = "#4fc3f7", steps: int = 20, delay: float = 0.025):
374
+ is_float = isinstance(final_val, float)
375
+ for i in range(1, steps + 1):
376
+ v = final_val * i / steps
377
+ display = f"{v:.1f}" if is_float else str(int(v))
378
+ placeholder.markdown(
379
+ f"""
380
+ <div style='text-align:center;padding:12px 6px;border-radius:12px;
381
+ background:#1a1a2e;border:1px solid #2a2a4e;margin:4px 0;'>
382
+ <div style='font-size:2rem;font-weight:800;color:{color};
383
+ line-height:1.1;'>{display}</div>
384
+ <div style='font-size:0.75rem;color:#9e9e9e;margin-top:4px;'>{label}</div>
385
+ </div>
386
+ """,
387
+ unsafe_allow_html=True,
388
+ )
389
+ time.sleep(delay)
390
+
391
+
392
+ # ─────────────────────────────────────────────────────────────────────────────
393
+ # Active-learning queue helpers
394
+ # ─────────────────────────────────────────────────────────────────────────────
395
+
396
+ def _ensure_dirs():
397
+ QUEUE_DIR.mkdir(parents=True, exist_ok=True)
398
+ CORRECTIONS_DIR.mkdir(parents=True, exist_ok=True)
399
+
400
+
401
+ def add_to_queue(image_array: np.ndarray, reason: str = "batch",
402
+ nuc_mask=None, myo_mask=None, metadata: dict = None):
403
+ _ensure_dirs()
404
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
405
+ meta = {**(metadata or {}), "reason": reason, "timestamp": ts}
406
+
407
+ if nuc_mask is not None and myo_mask is not None:
408
+ folder = CORRECTIONS_DIR / ts
409
+ folder.mkdir(parents=True, exist_ok=True)
410
+ Image.fromarray(image_array).save(folder / "image.png")
411
+ Image.fromarray((nuc_mask > 0).astype(np.uint8) * 255).save(folder / "nuclei_mask.png")
412
+ Image.fromarray((myo_mask > 0).astype(np.uint8) * 255).save(folder / "myotube_mask.png")
413
+ (folder / "meta.json").write_text(json.dumps({**meta, "has_masks": True}, indent=2))
414
+ else:
415
+ Image.fromarray(image_array).save(QUEUE_DIR / f"{ts}.png")
416
+ (QUEUE_DIR / f"{ts}.json").write_text(json.dumps({**meta, "has_masks": False}, indent=2))
417
+
418
+
419
+ # ─────────────────────────────────────────────────────────────────────────────
420
+ # Model (architecture identical to training script)
421
+ # ─────────────────────────────────────────────────────────────────────────────
422
+
423
+ class DoubleConv(nn.Module):
424
+ def __init__(self, in_ch, out_ch):
425
+ super().__init__()
426
+ self.net = nn.Sequential(
427
+ nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
428
+ nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
429
+ )
430
+ def forward(self, x): return self.net(x)
431
+
432
+
433
+ class UNet(nn.Module):
434
+ def __init__(self, in_ch=2, out_ch=2, base=32):
435
+ super().__init__()
436
+ self.d1 = DoubleConv(in_ch, base); self.p1 = nn.MaxPool2d(2)
437
+ self.d2 = DoubleConv(base, base*2); self.p2 = nn.MaxPool2d(2)
438
+ self.d3 = DoubleConv(base*2, base*4); self.p3 = nn.MaxPool2d(2)
439
+ self.d4 = DoubleConv(base*4, base*8); self.p4 = nn.MaxPool2d(2)
440
+ self.bn = DoubleConv(base*8, base*16)
441
+ self.u4 = nn.ConvTranspose2d(base*16, base*8, 2, 2); self.du4 = DoubleConv(base*16, base*8)
442
+ self.u3 = nn.ConvTranspose2d(base*8, base*4, 2, 2); self.du3 = DoubleConv(base*8, base*4)
443
+ self.u2 = nn.ConvTranspose2d(base*4, base*2, 2, 2); self.du2 = DoubleConv(base*4, base*2)
444
+ self.u1 = nn.ConvTranspose2d(base*2, base, 2, 2); self.du1 = DoubleConv(base*2, base)
445
+ self.out = nn.Conv2d(base, out_ch, 1)
446
+
447
+ def forward(self, x):
448
+ d1=self.d1(x); p1=self.p1(d1)
449
+ d2=self.d2(p1); p2=self.p2(d2)
450
+ d3=self.d3(p2); p3=self.p3(d3)
451
+ d4=self.d4(p3); p4=self.p4(d4)
452
+ b=self.bn(p4)
453
+ x=self.u4(b); x=torch.cat([x,d4],1); x=self.du4(x)
454
+ x=self.u3(x); x=torch.cat([x,d3],1); x=self.du3(x)
455
+ x=self.u2(x); x=torch.cat([x,d2],1); x=self.du2(x)
456
+ x=self.u1(x); x=torch.cat([x,d1],1); x=self.du1(x)
457
+ return self.out(x)
458
+
459
+
460
+ @st.cache_resource
461
+ def load_model(device: str):
462
+ local = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILENAME,
463
+ force_download=True)
464
+ file_sha = sha256_file(local)
465
+ mtime = time.ctime(os.path.getmtime(local))
466
+ size_mb = os.path.getsize(local) / 1e6
467
+
468
+ st.sidebar.markdown("### πŸ” Model debug")
469
+ st.sidebar.caption(f"Repo: `{MODEL_REPO_ID}`")
470
+ st.sidebar.caption(f"File: `{MODEL_FILENAME}`")
471
+ st.sidebar.caption(f"Size: {size_mb:.2f} MB")
472
+ st.sidebar.caption(f"Modified: {mtime}")
473
+ st.sidebar.caption(f"SHA256: `{file_sha[:20]}…`")
474
+
475
+ ckpt = torch.load(local, map_location=device)
476
+ state = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt
477
+ model = UNet(in_ch=2, out_ch=2, base=32)
478
+ model.load_state_dict(state)
479
+ model.to(device).eval()
480
+ return model
481
+
482
+
483
+ # ─────────────────────────────────────────────────────────────────────────────
484
+ # PAGE CONFIG + CSS
485
+ # ─────────────────────────────────────────────────────────────────────────────
486
+
487
+ st.set_page_config(page_title="MyoSight β€” Myotube Analyser",
488
+ layout="wide", page_icon="πŸ”¬")
489
+
490
+ st.markdown("""
491
+ <style>
492
+ body, .stApp { background:#0e0e1a; color:#e0e0e0; }
493
+ .block-container { max-width:1200px; padding-top:1.25rem; }
494
+ h1,h2,h3,h4 { color:#90caf9; }
495
+ .flag-box {
496
+ background:#3e1a1a; border-left:4px solid #ef5350;
497
+ padding:10px 16px; border-radius:8px; margin:8px 0;
498
+ }
499
+ </style>
500
+ """, unsafe_allow_html=True)
501
+
502
+ st.title("πŸ”¬ MyoSight β€” Myotube & Nuclei Analyser")
503
+ device = "cuda" if torch.cuda.is_available() else "cpu"
504
+
505
+ # ─────────────────────────────────────────────────────────────────────────────
506
+ # SIDEBAR
507
+ # ─────────────────────────────────────────────────────────────────────────────
508
+ with st.sidebar:
509
+ st.caption(f"Device: **{device}**")
510
+
511
+ st.header("Input mapping")
512
+ src1 = st.selectbox("Model channel 1 (MyHC / myotubes)",
513
+ ["Red", "Green", "Blue", "Grayscale"], index=0)
514
+ inv1 = st.checkbox("Invert channel 1", value=False)
515
+ src2 = st.selectbox("Model channel 2 (DAPI / nuclei)",
516
+ ["Red", "Green", "Blue", "Grayscale"], index=2)
517
+ inv2 = st.checkbox("Invert channel 2", value=False)
518
+
519
+ st.header("Preprocessing")
520
+ image_size = st.select_slider("Model input size",
521
+ options=[256, 384, 512, 640, 768, 1024], value=512)
522
+
523
+ st.header("Thresholds")
524
+ thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.50, 0.01)
525
+ thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.50, 0.01)
526
+
527
+ st.header("Postprocessing")
528
+ min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
529
+ min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
530
+ nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
531
+ myo_close_radius = st.number_input("Myotube close radius", 0, 50, 3, 1)
532
+
533
+ st.header("Watershed (nuclei splitting)")
534
+ nuc_ws_min_dist = st.number_input("Min watershed distance", 1, 30, 3, 1)
535
+ nuc_ws_min_area = st.number_input("Min watershed area (px)", 1, 500, 6, 1)
536
+
537
+ st.header("Overlay")
538
+ nuc_hex = st.color_picker("Nuclei colour", "#00FFFF")
539
+ myo_hex = st.color_picker("Myotube colour", "#FF0000")
540
+ alpha = st.slider("Overlay alpha", 0.0, 1.0, 0.45, 0.01)
541
+ nuc_rgb = hex_to_rgb(nuc_hex)
542
+ myo_rgb = hex_to_rgb(myo_hex)
543
+ label_nuc = st.checkbox("Show nucleus IDs on overlay", value=True)
544
+ label_myo = st.checkbox("Show myotube IDs on overlay", value=True)
545
+
546
+ st.header("Surface area")
547
+ px_um = st.number_input("Pixel size (Β΅m) β€” set for real Β΅mΒ²",
548
+ value=1.0, min_value=0.01, step=0.01)
549
+
550
+ st.header("Active learning")
551
+ enable_al = st.toggle("Enable correction upload", value=True)
552
+
553
+ st.header("Metric definitions")
554
+ with st.expander("Fusion Index"):
555
+ st.write("100 Γ— (nuclei in myotubes with β‰₯2 nuclei) / total nuclei")
556
+ with st.expander("MyHC-positive nucleus"):
557
+ st.write("Counted if β‰₯10% of nucleus pixels overlap a myotube.")
558
+ with st.expander("Surface area"):
559
+ st.write("Pixel count Γ— px_umΒ². Set pixel size for real Β΅mΒ² values.")
560
+
561
+
562
+ # ─────────────────────────────────────────────────────────────────────────────
563
+ # FILE UPLOADER
564
+ # ─────────────────────────────────────────────────────────────────────────────
565
+ uploads = st.file_uploader(
566
+ "Upload 1+ images (png / jpg / tif). Public Space β€” don't upload sensitive data.",
567
+ type=["png", "jpg", "jpeg", "tif", "tiff"],
568
+ accept_multiple_files=True,
569
+ )
570
+
571
+ for key in ("df", "artifacts", "zip_bytes", "bio_metrics"):
572
+ if key not in st.session_state:
573
+ st.session_state[key] = None
574
+
575
+ if not uploads:
576
+ st.info("πŸ‘† Upload one or more fluorescence images to get started.")
577
+ st.stop()
578
+
579
+ model = load_model(device=device)
580
+
581
+ # ─────────────────────────────────────────────────────────────────────────────
582
+ # RUN ANALYSIS
583
+ # ─────────────────────────────────────────────────────────────────────────────
584
+ with st.form("run_form"):
585
+ run = st.form_submit_button("β–Ά Run / Rerun analysis", type="primary")
586
+
587
+ if run:
588
+ results = []
589
+ artifacts = {}
590
+ all_bio_metrics = {}
591
+ low_conf_flags = []
592
+ zip_buf = io.BytesIO()
593
+
594
+ with st.spinner("Analysing images…"):
595
+ with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
596
+ prog = st.progress(0.0)
597
+
598
+ for i, up in enumerate(uploads):
599
+ name = Path(up.name).stem
600
+ rgb_u8 = np.array(
601
+ Image.open(io.BytesIO(up.getvalue())).convert("RGB"),
602
+ dtype=np.uint8
603
+ )
604
+
605
+ ch1 = get_channel(rgb_u8, src1)
606
+ ch2 = get_channel(rgb_u8, src2)
607
+ if inv1: ch1 = 255 - ch1
608
+ if inv2: ch2 = 255 - ch2
609
+
610
+ H = W = int(image_size)
611
+ x1 = resize_u8_to_float01(ch1, W, H, Image.BILINEAR)
612
+ x2 = resize_u8_to_float01(ch2, W, H, Image.BILINEAR)
613
+ x = np.stack([x1, x2], 0).astype(np.float32)
614
+
615
+ x_t = torch.from_numpy(x).unsqueeze(0).to(device)
616
+ with torch.no_grad():
617
+ probs = torch.sigmoid(model(x_t)).cpu().numpy()[0]
618
+
619
+ # Confidence check
620
+ conf = float(np.mean([probs[0].max(), probs[1].max()]))
621
+ if conf < CONF_FLAG_THR:
622
+ low_conf_flags.append((name, conf))
623
+ add_to_queue(rgb_u8, reason="low_confidence",
624
+ metadata={"confidence": conf, "filename": up.name})
625
+
626
+ nuc_raw = (probs[0] > float(thr_nuc)).astype(np.uint8)
627
+ myo_raw = (probs[1] > float(thr_myo)).astype(np.uint8)
628
+
629
+ nuc_pp, myo_pp = postprocess_masks(
630
+ nuc_raw, myo_raw,
631
+ min_nuc_area=int(min_nuc_area),
632
+ min_myo_area=int(min_myo_area),
633
+ nuc_close_radius=int(nuc_close_radius),
634
+ myo_close_radius=int(myo_close_radius),
635
+ )
636
+
637
+ # Flat overlay for ZIP
638
+ simple_ov = make_simple_overlay(
639
+ rgb_u8, nuc_pp, myo_pp, nuc_rgb, myo_rgb, float(alpha)
640
+ )
641
+
642
+ # Label maps β€” shared across all three overlays
643
+ nuc_lab = label_nuclei_watershed(nuc_pp,
644
+ min_distance=int(nuc_ws_min_dist),
645
+ min_nuc_area=int(nuc_ws_min_area))
646
+ myo_lab = label_cc(myo_pp)
647
+
648
+ # Combined instance overlay (both nuclei + myotubes)
649
+ inst_ov = make_instance_overlay(rgb_u8, nuc_lab, myo_lab,
650
+ alpha=float(alpha),
651
+ label_nuclei=label_nuc,
652
+ label_myotubes=label_myo)
653
+
654
+ # Nuclei-only overlay
655
+ nuc_only_ov = make_instance_overlay(rgb_u8, nuc_lab,
656
+ np.zeros_like(myo_lab),
657
+ alpha=float(alpha),
658
+ label_nuclei=True,
659
+ label_myotubes=False)
660
+
661
+ # Myotubes-only overlay
662
+ myo_only_ov = make_instance_overlay(rgb_u8,
663
+ np.zeros_like(nuc_lab),
664
+ myo_lab,
665
+ alpha=float(alpha),
666
+ label_nuclei=False,
667
+ label_myotubes=True)
668
+
669
+ bio = compute_bio_metrics(
670
+ nuc_pp, myo_pp,
671
+ nuc_ws_min_distance=int(nuc_ws_min_dist),
672
+ nuc_ws_min_area=int(nuc_ws_min_area),
673
+ px_um=float(px_um),
674
+ )
675
+ per_areas = bio.pop("_per_myotube_areas", [])
676
+ bio["image"] = name
677
+ results.append(bio)
678
+ all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
679
+
680
+ artifacts[name] = {
681
+ "original" : png_bytes(rgb_u8),
682
+ "overlay" : png_bytes(inst_ov),
683
+ "nuc_only_ov" : png_bytes(nuc_only_ov),
684
+ "myo_only_ov" : png_bytes(myo_only_ov),
685
+ "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
686
+ "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
687
+ }
688
+
689
+ # ZIP contents
690
+ zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
691
+ zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_ov))
692
+ zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_ov))
693
+ zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_ov))
694
+ zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
695
+ zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
696
+ zf.writestr(f"{name}/nuclei_raw.png", png_bytes((nuc_raw*255).astype(np.uint8)))
697
+ zf.writestr(f"{name}/myotube_raw.png", png_bytes((myo_raw*255).astype(np.uint8)))
698
+
699
+ prog.progress((i + 1) / len(uploads))
700
+
701
+ df = pd.DataFrame(results).sort_values("image")
702
+ zf.writestr("metrics.csv", df.to_csv(index=False).encode("utf-8"))
703
+
704
+ st.session_state.df = df
705
+ st.session_state.artifacts = artifacts
706
+ st.session_state.zip_bytes = zip_buf.getvalue()
707
+ st.session_state.bio_metrics = all_bio_metrics
708
+
709
+ if low_conf_flags:
710
+ names_str = ", ".join(f"{n} (conf={c:.2f})" for n, c in low_conf_flags)
711
+ st.markdown(
712
+ f"<div class='flag-box'>⚠️ <b>Low-confidence images auto-queued for retraining:</b> "
713
+ f"{names_str}</div>",
714
+ unsafe_allow_html=True,
715
+ )
716
+
717
+ if st.session_state.df is None:
718
+ st.info("Click **β–Ά Run / Rerun analysis** to generate results.")
719
+ st.stop()
720
+
721
+ # ─────────────────────────────────────────────────────────────────────────────
722
+ # RESULTS TABLE + DOWNLOADS
723
+ # ─────────────────────────────────────────────────────────────────────────────
724
+ st.subheader("πŸ“‹ Results")
725
+ display_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
726
+ st.dataframe(st.session_state.df[display_cols], use_container_width=True, height=320)
727
+
728
+ c1, c2 = st.columns(2)
729
+ with c1:
730
+ st.download_button("⬇️ Download metrics.csv",
731
+ st.session_state.df[display_cols].to_csv(index=False).encode(),
732
+ file_name="metrics.csv", mime="text/csv")
733
+ with c2:
734
+ st.download_button("⬇️ Download results.zip",
735
+ st.session_state.zip_bytes,
736
+ file_name="results.zip", mime="application/zip")
737
+
738
+ st.divider()
739
+
740
+ # ─────────────────────────────────────────────────────────────────────────────
741
+ # PER-IMAGE PREVIEW + ANIMATED METRICS
742
+ # ─────────────────────────────────────────────────────────────────────────────
743
+ st.subheader("πŸ–ΌοΈ Image preview & live metrics")
744
+ names = list(st.session_state.artifacts.keys())
745
+ pick = st.selectbox("Select image", names)
746
+
747
+ col_img, col_metrics = st.columns([3, 2], gap="large")
748
+
749
+ with col_img:
750
+ tabs = st.tabs([
751
+ "πŸ”΅ Combined overlay",
752
+ "🟣 Nuclei only",
753
+ "🟠 Myotubes only",
754
+ "πŸ“· Original",
755
+ "⬜ Nuclei mask",
756
+ "⬜ Myotube mask",
757
+ ])
758
+ art = st.session_state.artifacts[pick]
759
+ bio_cur = st.session_state.bio_metrics.get(pick, {})
760
+ FIXED_W = 700
761
+ with tabs[0]:
762
+ st.image(art["overlay"], width=FIXED_W)
763
+ with tabs[1]:
764
+ n_nuc = bio_cur.get("total_nuclei", "β€”")
765
+ st.caption(f"**Nuclei count: {n_nuc}** β€” each nucleus has a unique ID label")
766
+ st.image(art["nuc_only_ov"], width=FIXED_W)
767
+ with tabs[2]:
768
+ n_myo = bio_cur.get("myotube_count", "β€”")
769
+ st.caption(f"**Myotube count: {n_myo}** β€” each myotube has a unique M-label")
770
+ st.image(art["myo_only_ov"], width=FIXED_W)
771
+ with tabs[3]:
772
+ st.image(art["original"], width=FIXED_W)
773
+ with tabs[4]:
774
+ st.image(art["nuc_pp"], width=FIXED_W)
775
+ with tabs[5]:
776
+ st.image(art["myo_pp"], width=FIXED_W)
777
+
778
+ with col_metrics:
779
+ st.markdown("#### πŸ“Š Live metrics")
780
+ bio = st.session_state.bio_metrics.get(pick, {})
781
+ per_areas = bio.get("_per_myotube_areas", [])
782
+
783
+ r1c1, r1c2, r1c3 = st.columns(3)
784
+ r2c1, r2c2, r2c3 = st.columns(3)
785
+ r3c1, r3c2, r3c3 = st.columns(3)
786
+
787
+ placeholders = {
788
+ "total_nuclei" : r1c1.empty(),
789
+ "myotube_count" : r1c2.empty(),
790
+ "myHC_positive_nuclei" : r1c3.empty(),
791
+ "myHC_positive_percentage": r2c1.empty(),
792
+ "fusion_index" : r2c2.empty(),
793
+ "avg_nuclei_per_myotube" : r2c3.empty(),
794
+ "total_area_um2" : r3c1.empty(),
795
+ "mean_area_um2" : r3c2.empty(),
796
+ "max_area_um2" : r3c3.empty(),
797
+ }
798
+
799
+ specs = [
800
+ ("total_nuclei", "Total nuclei", "#4fc3f7", False),
801
+ ("myotube_count", "Myotubes", "#ff8a65", False),
802
+ ("myHC_positive_nuclei", "MyHC⁺ nuclei", "#a5d6a7", False),
803
+ ("myHC_positive_percentage", "MyHC⁺ %", "#ce93d8", True),
804
+ ("fusion_index", "Fusion index %", "#80cbc4", True),
805
+ ("avg_nuclei_per_myotube", "Avg nuc/myotube", "#80deea", True),
806
+ ("total_area_um2", f"Total area (Β΅mΒ²)", "#fff176", True),
807
+ ("mean_area_um2", f"Mean area (Β΅mΒ²)", "#ffcc80", True),
808
+ ("max_area_um2", f"Max area (Β΅mΒ²)", "#ef9a9a", True),
809
+ ]
810
+
811
+ for key, label, color, is_float in specs:
812
+ val = bio.get(key, 0)
813
+ animated_metric(placeholders[key], label,
814
+ float(val) if is_float else int(val),
815
+ color=color)
816
+
817
+ if per_areas:
818
+ st.markdown("#### πŸ“ Per-myotube area")
819
+ area_df = pd.DataFrame({
820
+ "Myotube" : [f"M{i+1}" for i in range(len(per_areas))],
821
+ f"Area (Β΅mΒ²)" : per_areas,
822
+ }).set_index("Myotube")
823
+ st.bar_chart(area_df, height=220)
824
+
825
+ st.divider()
826
+
827
+ # ─────────────────────────────────────────────────────────────────────────────
828
+ # ACTIVE LEARNING β€” CORRECTION UPLOAD
829
+ # ─────────────────────────────────────────────────────────────────────────────
830
+ if enable_al:
831
+ st.subheader("🧠 Submit corrected labels (Active Learning)")
832
+ st.caption(
833
+ "Upload corrected binary masks for any image. "
834
+ "Corrections are saved to corrections/ and picked up "
835
+ "automatically by self_train.py at the next trigger check."
836
+ )
837
+
838
+ al_pick = st.selectbox("Correct masks for image", names, key="al_pick")
839
+ acol1, acol2 = st.columns(2)
840
+ with acol1:
841
+ corr_nuc = st.file_uploader("Corrected NUCLEI mask (PNG/TIF, binary 0/255)",
842
+ type=["png", "tif", "tiff"], key="nuc_corr")
843
+ with acol2:
844
+ corr_myo = st.file_uploader("Corrected MYOTUBE mask (PNG/TIF, binary 0/255)",
845
+ type=["png", "tif", "tiff"], key="myo_corr")
846
+
847
+ if st.button("βœ… Submit corrections", type="primary"):
848
+ if corr_nuc is None or corr_myo is None:
849
+ st.error("Please upload BOTH a nuclei mask and a myotube mask.")
850
+ else:
851
+ orig_bytes = st.session_state.artifacts[al_pick]["original"]
852
+ orig_rgb = np.array(Image.open(io.BytesIO(orig_bytes)).convert("RGB"))
853
+ nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
854
+ myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
855
+ add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
856
+ reason="user_correction",
857
+ metadata={"source_image": al_pick,
858
+ "timestamp": datetime.now().isoformat()})
859
+ st.success(
860
+ f"βœ… Corrections for **{al_pick}** saved to `corrections/`. "
861
+ "The model will retrain at the next scheduled cycle."
862
+ )
863
+
864
+ st.divider()
865
+
866
+ # ─────────────────────────────────────────────────────────────────────────────
867
+ # RETRAINING QUEUE STATUS
868
+ # ─────────────────────────────────────────────────────────────────────────────
869
+ with st.expander("πŸ”§ Self-training queue status"):
870
+ _ensure_dirs()
871
+ q_items = list(QUEUE_DIR.glob("*.json"))
872
+ c_items = list(CORRECTIONS_DIR.glob("*/meta.json"))
873
+
874
+ sq1, sq2 = st.columns(2)
875
+ sq1.metric("Images in retraining queue", len(q_items))
876
+ sq2.metric("Corrected label pairs", len(c_items))
877
+
878
+ if q_items:
879
+ reasons = {}
880
+ for p in q_items:
881
+ try:
882
+ r = json.loads(p.read_text()).get("reason", "unknown")
883
+ reasons[r] = reasons.get(r, 0) + 1
884
+ except Exception:
885
+ pass
886
+ st.write("Queue breakdown:", reasons)
887
+
888
+ manifest = Path("manifest.json")
889
+ if manifest.exists():
890
+ try:
891
+ history = json.loads(manifest.read_text())
892
+ if history:
893
+ st.markdown("**Last 5 retraining runs:**")
894
+ hist_df = pd.DataFrame(history[-5:])
895
+ st.dataframe(hist_df, use_container_width=True)
896
+ except Exception:
897
+ pass
898
+
899
+ if st.button("πŸ”„ Trigger retraining now"):
900
+ import subprocess
901
+ subprocess.Popen(["python", "self_train.py", "--manual"])
902
+ st.info("Retraining started in the background. Check terminal / logs for progress.")