skarugu commited on
Commit
5bf3d58
Β·
verified Β·
1 Parent(s): 5ebee97

Delete streamlit_app_v8

Browse files
Files changed (1) hide show
  1. streamlit_app_v8.py +0 -1710
streamlit_app_v8.py DELETED
@@ -1,1710 +0,0 @@
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
- Features:
8
- ✦ Animated count-up metrics (9 counters)
9
- ✦ Instance overlay β€” nucleus IDs (1,2,3…) + myotube IDs (M1,M2…)
10
- ✦ Contour outline overlay β€” see exactly what each detection covers
11
- ✦ Watershed nuclei splitting for accurate counts
12
- ✦ Myotube surface area (total, mean, max ¡m²) + per-tube bar chart
13
- ✦ Active learning β€” upload corrected masks β†’ saved to corrections/
14
- ✦ Low-confidence auto-flagging β†’ image queued for retraining
15
- ✦ Retraining queue status panel
16
- ✦ All original sidebar controls preserved
17
-
18
- v8 changes (validated against 57-well manual count dataset):
19
- ✦ REMOVED myotube closing β€” was merging adjacent myotubes into single blobs
20
- (caused 86% of images to undercount; r=0.245 β†’ see validation report).
21
- ✦ Unified postprocessing: opening + erode/dilate (matches training script).
22
- ✦ Added aspect-ratio shape filter to reject round debris false positives.
23
- ✦ Added contour outline tab per collaborator request.
24
- ✦ Fixed active learning correction upload bug (art["original"] β†’ art["rgb_u8"]).
25
- ✦ Unified threshold defaults across all scripts (thr_myo=0.40, thr_nuc=0.45).
26
- """
27
-
28
- import io
29
- import os
30
- import json
31
- import time
32
- import zipfile
33
- import hashlib
34
- from datetime import datetime
35
- from pathlib import Path
36
-
37
- import numpy as np
38
- import pandas as pd
39
- from PIL import Image
40
-
41
- import streamlit as st
42
- import streamlit.components.v1
43
- import torch
44
- import torch.nn as nn
45
- import matplotlib
46
- matplotlib.use("Agg")
47
- import matplotlib.pyplot as plt
48
- import matplotlib.patches as mpatches
49
- from huggingface_hub import hf_hub_download
50
-
51
- import scipy.ndimage as ndi
52
- from skimage.morphology import remove_small_objects, disk, closing, opening, binary_dilation, binary_erosion
53
- from skimage import measure
54
- from skimage.segmentation import watershed, find_boundaries
55
- from skimage.feature import peak_local_max
56
-
57
-
58
- # ─────────────────────────────────────────────────────────────────────────────
59
- # CONFIG ← edit these two lines to match your HF model repo
60
- # ─────────────────────────────────────────────────────────────────────────────
61
- MODEL_REPO_ID = "skarugu/myotube-unet"
62
- MODEL_FILENAME = "model_final.pt"
63
-
64
- CONF_FLAG_THR = 0.60 # images below this confidence are queued for retraining
65
- QUEUE_DIR = Path("retrain_queue")
66
- CORRECTIONS_DIR = Path("corrections")
67
-
68
-
69
- # ─────────────────────────────────────────────────────────────────────────────
70
- # Helpers (identical to originals so nothing breaks)
71
- # ─────────────────────────────────────────────────────────────────────────────
72
-
73
- def sha256_file(path: str) -> str:
74
- h = hashlib.sha256()
75
- with open(path, "rb") as f:
76
- for chunk in iter(lambda: f.read(1024 * 1024), b""):
77
- h.update(chunk)
78
- return h.hexdigest()
79
-
80
-
81
- def png_bytes(arr_u8: np.ndarray) -> bytes:
82
- buf = io.BytesIO()
83
- Image.fromarray(arr_u8).save(buf, format="PNG")
84
- return buf.getvalue()
85
-
86
-
87
- def resize_u8_to_float01(ch_u8: np.ndarray, W: int, H: int,
88
- resample=Image.BILINEAR) -> np.ndarray:
89
- im = Image.fromarray(ch_u8, mode="L").resize((W, H), resample=resample)
90
- return np.array(im, dtype=np.float32) / 255.0
91
-
92
-
93
- def get_channel(rgb_u8: np.ndarray, source: str) -> np.ndarray:
94
- if source == "Red": return rgb_u8[..., 0]
95
- if source == "Green": return rgb_u8[..., 1]
96
- if source == "Blue": return rgb_u8[..., 2]
97
- return (0.299*rgb_u8[...,0] + 0.587*rgb_u8[...,1] + 0.114*rgb_u8[...,2]).astype(np.uint8)
98
-
99
-
100
- def hex_to_rgb(h: str):
101
- h = h.lstrip("#")
102
- return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
103
-
104
-
105
- # ─────────────────────────────────────────────────────────────────────────────
106
- # Postprocessing
107
- # ─────────────────────────────────────────────────────────────────────────────
108
-
109
- def postprocess_masks(nuc_mask, myo_mask,
110
- min_nuc_area=20, min_myo_area=500,
111
- nuc_close_radius=2,
112
- myo_open_radius=2,
113
- myo_erode_radius=2,
114
- min_myo_aspect_ratio=0.0):
115
- """
116
- Clean up raw predicted masks.
117
-
118
- v8 β€” unified postprocessing (matches training script):
119
- Nuclei: optional closing to fill gaps, then remove small objects.
120
- Myotubes: opening (noise removal) β†’ erode+dilate (bridge breaking) β†’
121
- size filter β†’ optional aspect-ratio shape filter.
122
-
123
- NO closing for myotubes β€” closing merges adjacent myotubes into single
124
- connected components, causing severe undercounting in dense cultures.
125
- Validation showed r=0.245 with closing vs manual counts.
126
-
127
- myo_open_radius β€” disk radius for morphological opening. Removes small
128
- noise/debris without merging separate objects.
129
- myo_erode_radius β€” disk radius for erode+dilate bridge-breaking. Separates
130
- touching myotubes that share thin pixel bridges.
131
- Start at 2 px; increase for dense cultures. Set 0 to disable.
132
- min_myo_aspect_ratio β€” minimum major/minor axis ratio. Myotubes are elongated
133
- (aspect > 2); round debris blobs (aspect ~1) are rejected.
134
- Set 0 to disable. Recommended: 1.5–2.0 for sparse cultures.
135
- """
136
- # Nuclei β€” closing fills small gaps, then size filter
137
- nuc_bin = nuc_mask.astype(bool)
138
- if int(nuc_close_radius) > 0:
139
- nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
140
- nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
141
-
142
- # Myotubes β€” opening + erode/dilate + size filter + shape filter
143
- myo_bin = myo_mask.astype(bool)
144
- if int(myo_open_radius) > 0:
145
- myo_bin = opening(myo_bin, disk(int(myo_open_radius)))
146
- if int(myo_erode_radius) > 0:
147
- se = disk(int(myo_erode_radius))
148
- myo_bin = binary_erosion(myo_bin, se)
149
- myo_bin = binary_dilation(myo_bin, se) # re-dilate to restore size
150
- myo_bin = remove_small_objects(myo_bin, min_size=int(min_myo_area))
151
- if float(min_myo_aspect_ratio) > 0:
152
- myo_bin = _filter_by_aspect_ratio(myo_bin, float(min_myo_aspect_ratio))
153
- myo_clean = myo_bin.astype(np.uint8)
154
-
155
- return nuc_clean, myo_clean
156
-
157
-
158
- def _filter_by_aspect_ratio(mask_bin: np.ndarray, min_aspect: float) -> np.ndarray:
159
- """Keep only regions with major/minor axis ratio >= min_aspect."""
160
- lab, _ = ndi.label(mask_bin.astype(np.uint8))
161
- keep = np.zeros_like(mask_bin, dtype=bool)
162
- for prop in measure.regionprops(lab):
163
- if prop.minor_axis_length > 0:
164
- aspect = prop.major_axis_length / prop.minor_axis_length
165
- if aspect >= min_aspect:
166
- keep[lab == prop.label] = True
167
- else:
168
- # Degenerate (line-like) β€” keep it (very elongated)
169
- keep[lab == prop.label] = True
170
- return keep
171
-
172
-
173
- def label_cc(mask: np.ndarray) -> np.ndarray:
174
- lab, _ = ndi.label(mask.astype(np.uint8))
175
- return lab
176
-
177
-
178
- def split_large_myotubes(myo_lab: np.ndarray,
179
- nuc_lab: np.ndarray,
180
- max_area_px: int = 0,
181
- min_seeds: int = 2) -> np.ndarray:
182
- """
183
- Fix 2 + 3: Split oversized myotube regions using nucleus-seeded watershed.
184
-
185
- Addresses the core myotube merging problem: when adjacent or branching
186
- myotubes form a single connected region, this function splits them using
187
- nuclei centroids as seeds β€” the same principle as nucleus watershed splitting,
188
- applied at the myotube level.
189
-
190
- Algorithm
191
- ---------
192
- For each myotube region larger than max_area_px:
193
- 1. Find all nucleus centroids inside the region
194
- 2. If β‰₯ min_seeds nuclei found, run distance-transform watershed on
195
- the myotube mask using nucleus centroids as seeds
196
- 3. Replace the merged region with the resulting split sub-regions
197
- 4. Remove any resulting fragment smaller than min_myo_area
198
-
199
- Parameters
200
- ----------
201
- myo_lab : 2D int array β€” labelled myotube instances (from label_cc)
202
- nuc_lab : 2D int array β€” labelled nuclei (from label_nuclei_watershed)
203
- max_area_px : regions larger than this (in pixels) are candidates for splitting.
204
- Set to 0 to disable.
205
- min_seeds : minimum nucleus seeds required to attempt a split (default 2)
206
-
207
- Returns
208
- -------
209
- New labelled myotube array with split regions re-numbered sequentially.
210
- """
211
- if max_area_px <= 0:
212
- return myo_lab
213
-
214
- out = myo_lab.copy()
215
- next_id = int(myo_lab.max()) + 1
216
- H, W = myo_lab.shape
217
-
218
- for prop in measure.regionprops(myo_lab):
219
- if prop.area <= max_area_px:
220
- continue
221
-
222
- # Build binary mask for this single myotube region
223
- region_mask = (myo_lab == prop.label)
224
-
225
- # Find nucleus centroids inside this region
226
- seeds_img = np.zeros((H, W), dtype=np.int32)
227
- seed_count = 0
228
- for nuc_prop in measure.regionprops(nuc_lab):
229
- r, c = int(nuc_prop.centroid[0]), int(nuc_prop.centroid[1])
230
- if 0 <= r < H and 0 <= c < W and region_mask[r, c]:
231
- seeds_img[r, c] = nuc_prop.label
232
- seed_count += 1
233
-
234
- if seed_count < min_seeds:
235
- # Not enough nuclei to split β€” leave as is
236
- continue
237
-
238
- # Distance-transform watershed using nucleus seeds
239
- dist = ndi.distance_transform_edt(region_mask)
240
- result = watershed(-dist, seeds_img, mask=region_mask)
241
-
242
- # Clear the original region and write split sub-regions
243
- out[region_mask] = 0
244
- for sub_id in np.unique(result):
245
- if sub_id == 0:
246
- continue
247
- sub_mask = (result == sub_id)
248
- if sub_mask.sum() < 10: # discard tiny slivers
249
- continue
250
- out[sub_mask] = next_id
251
- next_id += 1
252
-
253
- # Re-number sequentially 1..N
254
- final = np.zeros_like(out)
255
- for new_id, old_id in enumerate(np.unique(out)[1:], start=1):
256
- final[out == old_id] = new_id
257
-
258
- return final
259
-
260
-
261
- def label_nuclei_watershed(nuc_bin: np.ndarray,
262
- min_distance: int = 3,
263
- min_nuc_area: int = 6) -> np.ndarray:
264
- """Split touching nuclei via distance-transform watershed."""
265
- nuc_bin = remove_small_objects(nuc_bin.astype(bool), min_size=min_nuc_area)
266
- if nuc_bin.sum() == 0:
267
- return np.zeros_like(nuc_bin, dtype=np.int32)
268
-
269
- dist = ndi.distance_transform_edt(nuc_bin)
270
- coords = peak_local_max(dist, labels=nuc_bin,
271
- min_distance=min_distance, exclude_border=False)
272
- markers = np.zeros_like(nuc_bin, dtype=np.int32)
273
- for i, (r, c) in enumerate(coords, start=1):
274
- markers[r, c] = i
275
-
276
- if markers.max() == 0:
277
- return ndi.label(nuc_bin.astype(np.uint8))[0].astype(np.int32)
278
-
279
- return watershed(-dist, markers, mask=nuc_bin).astype(np.int32)
280
-
281
-
282
- # ─────────────────────────────────────────────────────────────────────────────
283
- # Surface area (new)
284
- # ─────────────────────────────────────────────────────────────────────────────
285
-
286
- def compute_surface_area(myo_mask: np.ndarray, px_um: float = 1.0) -> dict:
287
- lab = label_cc(myo_mask)
288
- px_area = px_um ** 2
289
- per = [round(prop.area * px_area, 2) for prop in measure.regionprops(lab)]
290
- return {
291
- "total_area_um2" : round(sum(per), 2),
292
- "mean_area_um2" : round(float(np.mean(per)) if per else 0.0, 2),
293
- "max_area_um2" : round(float(np.max(per)) if per else 0.0, 2),
294
- "per_myotube_areas" : per,
295
- }
296
-
297
-
298
- # ─────────────────────────────────────────────────────────────────────────────
299
- # Cytoplasm-hole nucleus classifier (MyoFuse method, Lair et al. 2025)
300
- # ─────────────────────────────────────────────────────────────────────────────
301
-
302
- def classify_nucleus_in_myotube(nuc_coords: np.ndarray,
303
- myc_channel: np.ndarray,
304
- myo_mask_full: np.ndarray,
305
- ring_width: int = 6,
306
- hole_ratio_thr: float = 0.85) -> bool:
307
- """
308
- Determine whether a nucleus is GENUINELY inside a myotube
309
- using the cytoplasm-hole method (MyoFuse, Lair et al. 2025).
310
-
311
- A fused nucleus inside a myotube physically displaces the cytoplasm,
312
- creating a local dip (dark "hole") in the MyHC signal beneath it.
313
- An unfused nucleus sitting on top of a myotube in Z does NOT create
314
- this dip β€” its underlying MyHC signal stays bright.
315
-
316
- Algorithm
317
- ---------
318
- 1. Check the nucleus pixel footprint overlaps the myotube mask at all.
319
- If not β€” definitely not fused.
320
- 2. Measure mean MyHC intensity under the nucleus pixels (I_nuc).
321
- 3. Build a ring around the nucleus (dilated - eroded footprint) clipped
322
- to the myotube mask β€” this is the local cytoplasm reference (I_ring).
323
- 4. Compute hole_ratio = I_nuc / I_ring.
324
- If hole_ratio < hole_ratio_thr β†’ nucleus has created a cytoplasmic
325
- hole β†’ genuinely fused.
326
- If hole_ratio β‰₯ hole_ratio_thr β†’ nucleus sits on top in Z β†’ not fused.
327
-
328
- Parameters
329
- ----------
330
- nuc_coords : (N,2) array of (row, col) pixel coords for this nucleus
331
- myc_channel : 2D float32 array of MyHC channel at FULL image resolution
332
- myo_mask_full : 2D binary mask of myotubes at FULL image resolution
333
- ring_width : dilation radius (px) for the cytoplasm ring
334
- hole_ratio_thr: threshold below which the nucleus is counted as fused
335
- (default 0.85, consistent with MyoFuse calibration)
336
-
337
- Returns
338
- -------
339
- True if nucleus is genuinely fused (inside myotube cytoplasm)
340
- """
341
- rows, cols = nuc_coords[:, 0], nuc_coords[:, 1]
342
- H, W = myc_channel.shape
343
-
344
- # Step 1 β€” must overlap myotube mask at all
345
- in_myo = myo_mask_full[rows, cols]
346
- if in_myo.sum() == 0:
347
- return False
348
-
349
- # Step 2 β€” mean MyHC under nucleus
350
- I_nuc = float(myc_channel[rows, cols].mean())
351
-
352
- # Step 3 β€” build ring around nucleus footprint, clipped to myotube mask
353
- nuc_footprint = np.zeros((H, W), dtype=bool)
354
- nuc_footprint[rows, cols] = True
355
-
356
- nuc_dilated = binary_dilation(nuc_footprint, footprint=disk(ring_width))
357
- ring_mask = nuc_dilated & ~nuc_footprint & myo_mask_full.astype(bool)
358
-
359
- if ring_mask.sum() < 4:
360
- # Ring too small (nucleus near edge of myotube) β€” fall back to overlap test
361
- return in_myo.mean() >= 0.10
362
-
363
- I_ring = float(myc_channel[ring_mask].mean())
364
-
365
- if I_ring < 1e-6:
366
- # No myotube signal at all in ring β€” something is wrong, use overlap
367
- return in_myo.mean() >= 0.10
368
-
369
- # Step 4 β€” hole ratio test
370
- hole_ratio = I_nuc / I_ring
371
- return hole_ratio < hole_ratio_thr
372
-
373
-
374
- # ─────────────────────────────────────────────────────────────────────────────
375
- # Biological metrics (counting + fusion + surface area)
376
- # ─────────────────────────────────────────────────────────────────────────────
377
-
378
- def compute_bio_metrics(nuc_mask, myo_mask,
379
- myc_channel_full=None,
380
- min_overlap_frac=0.10,
381
- nuc_ws_min_distance=3,
382
- nuc_ws_min_area=6,
383
- px_um=1.0,
384
- ring_width=6,
385
- hole_ratio_thr=0.85) -> dict:
386
- """
387
- Compute all biological metrics.
388
-
389
- If myc_channel_full (the raw MyHC grayscale image at original resolution)
390
- is supplied, uses the cytoplasm-hole method (MyoFuse, Lair et al. 2025)
391
- to classify each nucleus β€” eliminates Z-stack overlap false positives and
392
- gives an accurate, non-overestimated fusion index.
393
-
394
- If myc_channel_full is None, falls back to the original pixel-overlap
395
- method for backward compatibility.
396
- """
397
- nuc_lab = label_nuclei_watershed(nuc_mask,
398
- min_distance=nuc_ws_min_distance,
399
- min_nuc_area=nuc_ws_min_area)
400
- myo_lab = label_cc(myo_mask)
401
- total = int(nuc_lab.max())
402
-
403
- # Resize masks/channel to the SAME space for comparison
404
- # nuc_lab and myo_mask are at model resolution (e.g. 512Γ—512).
405
- # myc_channel_full is at original image resolution.
406
- # We resize everything to original resolution for the cytoplasm-hole test.
407
- if myc_channel_full is not None:
408
- H_full, W_full = myc_channel_full.shape
409
- # Resize label maps up to original resolution
410
- nuc_lab_full = np.array(
411
- Image.fromarray(nuc_lab.astype(np.int32))
412
- .resize((W_full, H_full), Image.NEAREST)
413
- )
414
- myo_mask_full = np.array(
415
- Image.fromarray((myo_mask * 255).astype(np.uint8))
416
- .resize((W_full, H_full), Image.NEAREST)
417
- ) > 0
418
- # Normalise MyHC channel to 0-1 float
419
- myc_f = myc_channel_full.astype(np.float32)
420
- if myc_f.max() > 1.0:
421
- myc_f = myc_f / 255.0
422
- else:
423
- nuc_lab_full = nuc_lab
424
- myo_mask_full = myo_mask.astype(bool)
425
- myc_f = None
426
-
427
- pos, nm = 0, {}
428
- for prop in measure.regionprops(nuc_lab_full):
429
- coords = prop.coords # (N,2) in full-res space
430
-
431
- if myc_f is not None:
432
- # ── Cytoplasm-hole method (accurate, MyoFuse 2025) ────────────────
433
- is_fused = classify_nucleus_in_myotube(
434
- coords, myc_f, myo_mask_full,
435
- ring_width=ring_width,
436
- hole_ratio_thr=hole_ratio_thr,
437
- )
438
- else:
439
- # ── Legacy pixel-overlap fallback ─────────────────────────────────
440
- ids = myo_mask_full.astype(np.uint8)[coords[:, 0], coords[:, 1]]
441
- frac = ids.sum() / max(len(coords), 1)
442
- is_fused = frac >= min_overlap_frac
443
-
444
- if is_fused:
445
- # Find which myotube this nucleus belongs to (use model-res myo_lab)
446
- # Scale coords back to model resolution
447
- if myc_f is not None:
448
- r_m = np.clip((coords[:, 0] * nuc_lab.shape[0] / H_full).astype(int),
449
- 0, nuc_lab.shape[0] - 1)
450
- c_m = np.clip((coords[:, 1] * nuc_lab.shape[1] / W_full).astype(int),
451
- 0, nuc_lab.shape[1] - 1)
452
- ids_mt = myo_lab[r_m, c_m]
453
- else:
454
- ids_mt = myo_lab[coords[:, 0], coords[:, 1]]
455
-
456
- ids_mt = ids_mt[ids_mt > 0]
457
- if ids_mt.size > 0:
458
- unique, counts = np.unique(ids_mt, return_counts=True)
459
- mt = int(unique[np.argmax(counts)])
460
- nm.setdefault(mt, []).append(prop.label)
461
- pos += 1
462
-
463
- per = [len(v) for v in nm.values()]
464
- fused = sum(n for n in per if n >= 2)
465
- fi = 100.0 * fused / total if total else 0.0
466
- pct = 100.0 * pos / total if total else 0.0
467
- avg = float(np.mean(per)) if per else 0.0
468
-
469
- sa = compute_surface_area(myo_mask, px_um=px_um)
470
-
471
- return {
472
- "total_nuclei" : total,
473
- "myHC_positive_nuclei" : int(pos),
474
- "myHC_positive_percentage" : round(pct, 2),
475
- "nuclei_fused" : int(fused),
476
- "myotube_count" : int(len(per)),
477
- "avg_nuclei_per_myotube" : round(avg, 2),
478
- "fusion_index" : round(fi, 2),
479
- "total_area_um2" : sa["total_area_um2"],
480
- "mean_area_um2" : sa["mean_area_um2"],
481
- "max_area_um2" : sa["max_area_um2"],
482
- "_per_myotube_areas" : sa["per_myotube_areas"],
483
- }
484
-
485
-
486
- # ─────────────────────────────────────────────────────────────────────────────
487
- # Overlay helpers
488
- # ─────────────────────────────────────────────────────────────────────────────
489
-
490
- def make_simple_overlay(rgb_u8, nuc_mask, myo_mask, nuc_color, myo_color, alpha):
491
- """Flat colour overlay β€” used for the ZIP export (fast, no matplotlib)."""
492
- base = rgb_u8.astype(np.float32)
493
- H0, W0 = rgb_u8.shape[:2]
494
- nuc = np.array(Image.fromarray((nuc_mask*255).astype(np.uint8))
495
- .resize((W0, H0), Image.NEAREST)) > 0
496
- myo = np.array(Image.fromarray((myo_mask*255).astype(np.uint8))
497
- .resize((W0, H0), Image.NEAREST)) > 0
498
- out = base.copy()
499
- for mask, color in [(myo, myo_color), (nuc, nuc_color)]:
500
- c = np.array(color, dtype=np.float32)
501
- out[mask] = (1 - alpha) * out[mask] + alpha * c
502
- return np.clip(out, 0, 255).astype(np.uint8)
503
-
504
-
505
- def make_coloured_overlay(rgb_u8: np.ndarray,
506
- nuc_lab: np.ndarray,
507
- myo_lab: np.ndarray,
508
- alpha: float = 0.45,
509
- nuc_color: tuple = None,
510
- myo_color: tuple = None) -> np.ndarray:
511
- """
512
- Colour the mask regions only β€” NO text baked in.
513
- Returns an RGB uint8 array at original image resolution.
514
-
515
- nuc_color / myo_color: RGB tuple e.g. (0, 255, 255).
516
- If None, uses per-instance colourmaps (cool / autumn).
517
- If provided, uses a flat solid colour for all instances of that type β€”
518
- this is what the sidebar colour pickers control.
519
- """
520
- orig_h, orig_w = rgb_u8.shape[:2]
521
- nuc_cmap = plt.cm.get_cmap("cool")
522
- myo_cmap = plt.cm.get_cmap("autumn")
523
-
524
- def _resize_lab(lab, h, w):
525
- return np.array(
526
- Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
527
- )
528
-
529
- nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
530
- myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
531
- n_nuc = int(nuc_disp.max())
532
- n_myo = int(myo_disp.max())
533
-
534
- base = rgb_u8.astype(np.float32).copy()
535
- if n_myo > 0:
536
- mask = myo_disp > 0
537
- if myo_color is not None:
538
- colour_layer = np.array(myo_color, dtype=np.float32)
539
- base[mask] = (1 - alpha) * base[mask] + alpha * colour_layer
540
- else:
541
- myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
542
- myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
543
- base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
544
-
545
- if n_nuc > 0:
546
- mask = nuc_disp > 0
547
- if nuc_color is not None:
548
- colour_layer = np.array(nuc_color, dtype=np.float32)
549
- base[mask] = (1 - alpha) * base[mask] + alpha * colour_layer
550
- else:
551
- nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
552
- nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
553
- base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
554
-
555
- return np.clip(base, 0, 255).astype(np.uint8)
556
-
557
-
558
- def make_outline_overlay(rgb_u8: np.ndarray,
559
- nuc_lab: np.ndarray,
560
- myo_lab: np.ndarray,
561
- nuc_color: tuple = (0, 255, 255),
562
- myo_color: tuple = (0, 255, 0),
563
- line_width: int = 2) -> np.ndarray:
564
- """
565
- Draw contour outlines around each detected instance on the original image.
566
- Shows exactly what the model considers each myotube/nucleus boundary.
567
- """
568
- orig_h, orig_w = rgb_u8.shape[:2]
569
-
570
- def _resize_lab(lab, h, w):
571
- return np.array(
572
- Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
573
- )
574
-
575
- nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
576
- myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
577
-
578
- out = rgb_u8.copy()
579
-
580
- # Myotube outlines
581
- if myo_disp.max() > 0:
582
- myo_bounds = find_boundaries(myo_disp, mode='outer')
583
- if line_width > 1:
584
- myo_bounds = binary_dilation(myo_bounds, footprint=disk(line_width - 1))
585
- out[myo_bounds] = myo_color
586
-
587
- # Nuclei outlines
588
- if nuc_disp.max() > 0:
589
- nuc_bounds = find_boundaries(nuc_disp, mode='outer')
590
- if line_width > 1:
591
- nuc_bounds = binary_dilation(nuc_bounds, footprint=disk(max(line_width - 2, 0)))
592
- out[nuc_bounds] = nuc_color
593
-
594
- return out
595
-
596
-
597
- def collect_label_positions(nuc_lab: np.ndarray,
598
- myo_lab: np.ndarray,
599
- img_w: int, img_h: int) -> dict:
600
- """
601
- Collect centroid positions for every nucleus and myotube,
602
- scaled to the original image pixel dimensions.
603
- Returns:
604
- { "nuclei": [ {"id": 1, "x": 123.4, "y": 56.7}, ... ],
605
- "myotubes": [ {"id": "M1","x": 200.1, "y": 300.5}, ... ] }
606
- """
607
- sx = img_w / nuc_lab.shape[1]
608
- sy = img_h / nuc_lab.shape[0]
609
-
610
- nuclei = []
611
- for prop in measure.regionprops(nuc_lab):
612
- r, c = prop.centroid
613
- nuclei.append({"id": str(prop.label), "x": round(c * sx, 1), "y": round(r * sy, 1)})
614
-
615
- sx2 = img_w / myo_lab.shape[1]
616
- sy2 = img_h / myo_lab.shape[0]
617
- myotubes = []
618
- for prop in measure.regionprops(myo_lab):
619
- r, c = prop.centroid
620
- myotubes.append({"id": f"M{prop.label}", "x": round(c * sx2, 1), "y": round(r * sy2, 1)})
621
-
622
- return {"nuclei": nuclei, "myotubes": myotubes}
623
-
624
-
625
- def make_svg_viewer(img_b64: str,
626
- img_w: int, img_h: int,
627
- label_data: dict,
628
- show_nuclei: bool = True,
629
- show_myotubes: bool = True,
630
- nuc_font_size: int = 11,
631
- myo_font_size: int = 22,
632
- viewer_height: int = 620) -> str:
633
- """
634
- Build a self-contained HTML string with:
635
- - A pan-and-zoom SVG viewer (mouse wheel + click-drag)
636
- - The coloured overlay PNG as the background
637
- - SVG <text> labels that stay pixel-perfect at any zoom level
638
- - A font-size slider that updates label sizes live
639
- - Toggle buttons for nuclei / myotubes labels
640
- - Count badges in the top-right corner
641
-
642
- Parameters
643
- ----------
644
- img_b64 : base64-encoded PNG of the coloured overlay (no text)
645
- img_w, img_h : original pixel dimensions of the image
646
- label_data : output of collect_label_positions()
647
- show_nuclei : initial visibility of nucleus labels
648
- show_myotubes : initial visibility of myotube labels
649
- nuc_font_size : initial nucleus label font size (px)
650
- myo_font_size : initial myotube label font size (px)
651
- viewer_height : height of the viewer div in pixels
652
- """
653
- import json as _json
654
- labels_json = _json.dumps(label_data)
655
- n_nuc = len(label_data.get("nuclei", []))
656
- n_myo = len(label_data.get("myotubes", []))
657
-
658
- show_nuc_js = "true" if show_nuclei else "false"
659
- show_myo_js = "true" if show_myotubes else "false"
660
-
661
- html = f"""
662
- <style>
663
- .myo-viewer-wrap {{
664
- background: #0e0e1a;
665
- border: 1px solid #2a2a4e;
666
- border-radius: 10px;
667
- overflow: hidden;
668
- position: relative;
669
- user-select: none;
670
- }}
671
- .myo-toolbar {{
672
- display: flex;
673
- align-items: center;
674
- gap: 12px;
675
- padding: 8px 14px;
676
- background: #13132a;
677
- border-bottom: 1px solid #2a2a4e;
678
- flex-wrap: wrap;
679
- }}
680
- .myo-badge {{
681
- background: #1a1a3e;
682
- border: 1px solid #3a3a6e;
683
- border-radius: 6px;
684
- padding: 3px 10px;
685
- color: #e0e0e0;
686
- font-size: 13px;
687
- font-family: Arial, sans-serif;
688
- white-space: nowrap;
689
- }}
690
- .myo-badge span {{ font-weight: bold; }}
691
- .myo-btn {{
692
- padding: 4px 12px;
693
- border-radius: 6px;
694
- border: 1px solid #444;
695
- cursor: pointer;
696
- font-size: 12px;
697
- font-family: Arial, sans-serif;
698
- font-weight: bold;
699
- transition: opacity 0.15s;
700
- }}
701
- .myo-btn.nuc {{ background: #003366; color: white; border-color: #4fc3f7; }}
702
- .myo-btn.myo {{ background: #8B0000; color: white; border-color: #ff6666; }}
703
- .myo-btn.off {{ opacity: 0.35; }}
704
- .myo-btn.reset {{ background: #1a1a2e; color: #90caf9; border-color: #3a3a6e; }}
705
- .myo-slider-wrap {{
706
- display: flex;
707
- align-items: center;
708
- gap: 6px;
709
- color: #aaa;
710
- font-size: 12px;
711
- font-family: Arial, sans-serif;
712
- }}
713
- .myo-slider-wrap input {{ width: 70px; accent-color: #4fc3f7; cursor: pointer; }}
714
- .myo-hint {{
715
- margin-left: auto;
716
- color: #555;
717
- font-size: 11px;
718
- font-family: Arial, sans-serif;
719
- white-space: nowrap;
720
- }}
721
- .myo-svg-wrap {{
722
- width: 100%;
723
- height: {viewer_height}px;
724
- overflow: hidden;
725
- cursor: grab;
726
- position: relative;
727
- }}
728
- .myo-svg-wrap:active {{ cursor: grabbing; }}
729
- svg.myo-svg {{
730
- width: 100%;
731
- height: 100%;
732
- display: block;
733
- }}
734
- </style>
735
-
736
- <div class="myo-viewer-wrap" id="myoViewer">
737
- <div class="myo-toolbar">
738
- <div class="myo-badge">πŸ”΅ Nuclei &nbsp;<span id="nucCount">{n_nuc}</span></div>
739
- <div class="myo-badge">πŸ”΄ Myotubes &nbsp;<span id="myoCount">{n_myo}</span></div>
740
- <button class="myo-btn nuc" id="btnNuc" onclick="toggleLayer('nuc')">Nuclei IDs</button>
741
- <button class="myo-btn myo" id="btnMyo" onclick="toggleLayer('myo')">Myotube IDs</button>
742
- <button class="myo-btn reset" onclick="resetView()">⟳ Reset</button>
743
- <div class="myo-slider-wrap">
744
- Nucleus size:
745
- <input type="range" id="slNuc" min="4" max="40" value="{nuc_font_size}"
746
- oninput="setFontSize('nuc', this.value)" />
747
- <span id="lblNuc">{nuc_font_size}px</span>
748
- </div>
749
- <div class="myo-slider-wrap">
750
- Myotube size:
751
- <input type="range" id="slMyo" min="8" max="60" value="{myo_font_size}"
752
- oninput="setFontSize('myo', this.value)" />
753
- <span id="lblMyo">{myo_font_size}px</span>
754
- </div>
755
- <div class="myo-hint">Scroll to zoom &nbsp;Β·&nbsp; Drag to pan</div>
756
- </div>
757
-
758
- <div class="myo-svg-wrap" id="svgWrap">
759
- <svg class="myo-svg" id="mainSvg"
760
- viewBox="0 0 {img_w} {img_h}"
761
- preserveAspectRatio="xMidYMid meet">
762
- <defs>
763
- <filter id="dropshadow" x="-5%" y="-5%" width="110%" height="110%">
764
- <feDropShadow dx="0" dy="0" stdDeviation="1.5" flood-color="#000" flood-opacity="0.8"/>
765
- </filter>
766
- </defs>
767
-
768
- <!-- background image β€” the coloured overlay PNG -->
769
- <image href="data:image/png;base64,{img_b64}"
770
- x="0" y="0" width="{img_w}" height="{img_h}"
771
- preserveAspectRatio="xMidYMid meet"/>
772
-
773
- <!-- nuclei labels group -->
774
- <g id="gNuc" visibility="{'visible' if show_nuclei else 'hidden'}">
775
- </g>
776
-
777
- <!-- myotube labels group -->
778
- <g id="gMyo" visibility="{'visible' if show_myotubes else 'hidden'}">
779
- </g>
780
- </svg>
781
- </div>
782
- </div>
783
-
784
- <script>
785
- (function() {{
786
- const labels = {labels_json};
787
- const IMG_W = {img_w};
788
- const IMG_H = {img_h};
789
-
790
- let nucFontSize = {nuc_font_size};
791
- let myoFontSize = {myo_font_size};
792
- let showNuc = {show_nuc_js};
793
- let showMyo = {show_myo_js};
794
-
795
- // ── Build SVG label elements ─────────────────────────────────────────────
796
- const NS = "http://www.w3.org/2000/svg";
797
-
798
- function makeLabelGroup(items, fontSize, bgColor, borderColor, isMyo) {{
799
- const frag = document.createDocumentFragment();
800
- items.forEach(item => {{
801
- const g = document.createElementNS(NS, "g");
802
- g.setAttribute("class", isMyo ? "lbl-myo" : "lbl-nuc");
803
-
804
- // Background rect β€” sized after text is measured
805
- const rect = document.createElementNS(NS, "rect");
806
- rect.setAttribute("rx", isMyo ? "4" : "3");
807
- rect.setAttribute("ry", isMyo ? "4" : "3");
808
- rect.setAttribute("fill", bgColor);
809
- rect.setAttribute("stroke", borderColor);
810
- rect.setAttribute("stroke-width", isMyo ? "1.5" : "0");
811
- rect.setAttribute("opacity", isMyo ? "0.93" : "0.90");
812
- rect.setAttribute("filter", "url(#dropshadow)");
813
-
814
- // Text
815
- const txt = document.createElementNS(NS, "text");
816
- txt.textContent = item.id;
817
- txt.setAttribute("x", item.x);
818
- txt.setAttribute("y", item.y);
819
- txt.setAttribute("text-anchor", "middle");
820
- txt.setAttribute("dominant-baseline", "central");
821
- txt.setAttribute("fill", "white");
822
- txt.setAttribute("font-family", "Arial, sans-serif");
823
- txt.setAttribute("font-weight", "bold");
824
- txt.setAttribute("font-size", fontSize);
825
- txt.setAttribute("paint-order", "stroke");
826
-
827
- g.appendChild(rect);
828
- g.appendChild(txt);
829
- frag.appendChild(g);
830
- }});
831
- return frag;
832
- }}
833
-
834
- function positionRects() {{
835
- // After elements are in the DOM, size and position the backing rects
836
- document.querySelectorAll(".lbl-nuc, .lbl-myo").forEach(g => {{
837
- const txt = g.querySelector("text");
838
- const rect = g.querySelector("rect");
839
- try {{
840
- const bb = txt.getBBox();
841
- const pad = parseFloat(txt.getAttribute("font-size")) * 0.22;
842
- rect.setAttribute("x", bb.x - pad);
843
- rect.setAttribute("y", bb.y - pad);
844
- rect.setAttribute("width", bb.width + pad * 2);
845
- rect.setAttribute("height", bb.height + pad * 2);
846
- }} catch(e) {{}}
847
- }});
848
- }}
849
-
850
- function rebuildLabels() {{
851
- const gNuc = document.getElementById("gNuc");
852
- const gMyo = document.getElementById("gMyo");
853
- gNuc.innerHTML = "";
854
- gMyo.innerHTML = "";
855
- gNuc.appendChild(makeLabelGroup(labels.nuclei, nucFontSize, "#003366", "none", false));
856
- gMyo.appendChild(makeLabelGroup(labels.myotubes, myoFontSize, "#8B0000", "#FF6666", true));
857
- // rAF so the browser has laid out the text before we measure it
858
- requestAnimationFrame(positionRects);
859
- }}
860
-
861
- // ── Font size controls ────────────────────────────────────────────────────
862
- window.setFontSize = function(which, val) {{
863
- val = parseInt(val);
864
- if (which === "nuc") {{
865
- nucFontSize = val;
866
- document.getElementById("lblNuc").textContent = val + "px";
867
- document.querySelectorAll(".lbl-nuc text").forEach(t => t.setAttribute("font-size", val));
868
- }} else {{
869
- myoFontSize = val;
870
- document.getElementById("lblMyo").textContent = val + "px";
871
- document.querySelectorAll(".lbl-myo text").forEach(t => t.setAttribute("font-size", val));
872
- }}
873
- requestAnimationFrame(positionRects);
874
- }};
875
-
876
- // ── Layer toggles ─────────────────────────────────────────────────────────
877
- window.toggleLayer = function(which) {{
878
- if (which === "nuc") {{
879
- showNuc = !showNuc;
880
- document.getElementById("gNuc").setAttribute("visibility", showNuc ? "visible" : "hidden");
881
- document.getElementById("btnNuc").classList.toggle("off", !showNuc);
882
- }} else {{
883
- showMyo = !showMyo;
884
- document.getElementById("gMyo").setAttribute("visibility", showMyo ? "visible" : "hidden");
885
- document.getElementById("btnMyo").classList.toggle("off", !showMyo);
886
- }}
887
- }};
888
-
889
- // ── Pan + Zoom (pure SVG viewBox manipulation) ────────────────────────────
890
- const wrap = document.getElementById("svgWrap");
891
- const svg = document.getElementById("mainSvg");
892
-
893
- let vx = 0, vy = 0, vw = IMG_W, vh = IMG_H; // current viewBox
894
-
895
- function setVB() {{
896
- svg.setAttribute("viewBox", `${{vx}} ${{vy}} ${{vw}} ${{vh}}`);
897
- }}
898
-
899
- // Scroll to zoom β€” zoom toward mouse cursor
900
- wrap.addEventListener("wheel", e => {{
901
- e.preventDefault();
902
- const rect = wrap.getBoundingClientRect();
903
- const mx = (e.clientX - rect.left) / rect.width; // 0..1
904
- const my = (e.clientY - rect.top) / rect.height;
905
- const factor = e.deltaY < 0 ? 0.85 : 1.0 / 0.85;
906
- const nw = Math.min(IMG_W, Math.max(IMG_W * 0.05, vw * factor));
907
- const nh = Math.min(IMG_H, Math.max(IMG_H * 0.05, vh * factor));
908
- vx = vx + mx * (vw - nw);
909
- vy = vy + my * (vh - nh);
910
- vw = nw;
911
- vh = nh;
912
- // Clamp
913
- vx = Math.max(0, Math.min(IMG_W - vw, vx));
914
- vy = Math.max(0, Math.min(IMG_H - vh, vy));
915
- setVB();
916
- }}, {{ passive: false }});
917
-
918
- // Drag to pan
919
- let dragging = false, dragX0, dragY0, vx0, vy0;
920
-
921
- wrap.addEventListener("mousedown", e => {{
922
- dragging = true;
923
- dragX0 = e.clientX; dragY0 = e.clientY;
924
- vx0 = vx; vy0 = vy;
925
- }});
926
- window.addEventListener("mousemove", e => {{
927
- if (!dragging) return;
928
- const rect = wrap.getBoundingClientRect();
929
- const scaleX = vw / rect.width;
930
- const scaleY = vh / rect.height;
931
- vx = Math.max(0, Math.min(IMG_W - vw, vx0 - (e.clientX - dragX0) * scaleX));
932
- vy = Math.max(0, Math.min(IMG_H - vh, vy0 - (e.clientY - dragY0) * scaleY));
933
- setVB();
934
- }});
935
- window.addEventListener("mouseup", () => {{ dragging = false; }});
936
-
937
- // Touch support
938
- let t0 = null, pinch0 = null;
939
- wrap.addEventListener("touchstart", e => {{
940
- if (e.touches.length === 1) {{
941
- t0 = e.touches[0]; vx0 = vx; vy0 = vy;
942
- }} else if (e.touches.length === 2) {{
943
- pinch0 = Math.hypot(
944
- e.touches[0].clientX - e.touches[1].clientX,
945
- e.touches[0].clientY - e.touches[1].clientY
946
- );
947
- }}
948
- }}, {{ passive: true }});
949
- wrap.addEventListener("touchmove", e => {{
950
- e.preventDefault();
951
- if (e.touches.length === 1 && t0) {{
952
- const rect = wrap.getBoundingClientRect();
953
- vx = Math.max(0, Math.min(IMG_W - vw, vx0 - (e.touches[0].clientX - t0.clientX) * vw / rect.width));
954
- vy = Math.max(0, Math.min(IMG_H - vh, vy0 - (e.touches[0].clientY - t0.clientY) * vh / rect.height));
955
- setVB();
956
- }} else if (e.touches.length === 2 && pinch0 !== null) {{
957
- const dist = Math.hypot(
958
- e.touches[0].clientX - e.touches[1].clientX,
959
- e.touches[0].clientY - e.touches[1].clientY
960
- );
961
- const factor = pinch0 / dist;
962
- const nw = Math.min(IMG_W, Math.max(IMG_W * 0.05, vw * factor));
963
- const nh = Math.min(IMG_H, Math.max(IMG_H * 0.05, vh * factor));
964
- vw = nw; vh = nh;
965
- vx = Math.max(0, Math.min(IMG_W - vw, vx));
966
- vy = Math.max(0, Math.min(IMG_H - vh, vy));
967
- pinch0 = dist;
968
- setVB();
969
- }}
970
- }}, {{ passive: false }});
971
-
972
- // ── Reset view ────────────────────────────────────────────────────────────
973
- window.resetView = function() {{
974
- vx = 0; vy = 0; vw = IMG_W; vh = IMG_H;
975
- setVB();
976
- }};
977
-
978
- // ── Init ──────────────────────────────────────────────────────────────────
979
- rebuildLabels();
980
- }})();
981
- </script>
982
- """
983
- return html
984
-
985
-
986
- # ─────────────────────────────────────────────────────────────────────────────
987
- # Animated counter
988
- # ─────────────────────────────────────────────────────────────────────────────
989
-
990
- def animated_metric(placeholder, label: str, final_val,
991
- color: str = "#4fc3f7", steps: int = 20, delay: float = 0.025):
992
- is_float = isinstance(final_val, float)
993
- for i in range(1, steps + 1):
994
- v = final_val * i / steps
995
- display = f"{v:.1f}" if is_float else str(int(v))
996
- placeholder.markdown(
997
- f"""
998
- <div style='text-align:center;padding:12px 6px;border-radius:12px;
999
- background:#1a1a2e;border:1px solid #2a2a4e;margin:4px 0;'>
1000
- <div style='font-size:2rem;font-weight:800;color:{color};
1001
- line-height:1.1;'>{display}</div>
1002
- <div style='font-size:0.75rem;color:#9e9e9e;margin-top:4px;'>{label}</div>
1003
- </div>
1004
- """,
1005
- unsafe_allow_html=True,
1006
- )
1007
- time.sleep(delay)
1008
-
1009
-
1010
- # ─────────────────────────────────────────────────────────────────────────────
1011
- # Active-learning queue helpers
1012
- # ─────────────────────────────────────────────────────────────────────────────
1013
-
1014
- def _ensure_dirs():
1015
- QUEUE_DIR.mkdir(parents=True, exist_ok=True)
1016
- CORRECTIONS_DIR.mkdir(parents=True, exist_ok=True)
1017
-
1018
-
1019
- def add_to_queue(image_array: np.ndarray, reason: str = "batch",
1020
- nuc_mask=None, myo_mask=None, metadata: dict = None):
1021
- _ensure_dirs()
1022
- ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
1023
- meta = {**(metadata or {}), "reason": reason, "timestamp": ts}
1024
-
1025
- if nuc_mask is not None and myo_mask is not None:
1026
- folder = CORRECTIONS_DIR / ts
1027
- folder.mkdir(parents=True, exist_ok=True)
1028
- Image.fromarray(image_array).save(folder / "image.png")
1029
- Image.fromarray((nuc_mask > 0).astype(np.uint8) * 255).save(folder / "nuclei_mask.png")
1030
- Image.fromarray((myo_mask > 0).astype(np.uint8) * 255).save(folder / "myotube_mask.png")
1031
- (folder / "meta.json").write_text(json.dumps({**meta, "has_masks": True}, indent=2))
1032
- else:
1033
- Image.fromarray(image_array).save(QUEUE_DIR / f"{ts}.png")
1034
- (QUEUE_DIR / f"{ts}.json").write_text(json.dumps({**meta, "has_masks": False}, indent=2))
1035
-
1036
-
1037
- # ─────────────────────────────────────────────────────────────────────────────
1038
- # Model (architecture identical to training script)
1039
- # ─────────────────────────────────────────────────────────────────────────────
1040
-
1041
- class DoubleConv(nn.Module):
1042
- def __init__(self, in_ch, out_ch):
1043
- super().__init__()
1044
- self.net = nn.Sequential(
1045
- nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
1046
- nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
1047
- )
1048
- def forward(self, x): return self.net(x)
1049
-
1050
-
1051
- class UNet(nn.Module):
1052
- def __init__(self, in_ch=2, out_ch=2, base=32):
1053
- super().__init__()
1054
- self.d1 = DoubleConv(in_ch, base); self.p1 = nn.MaxPool2d(2)
1055
- self.d2 = DoubleConv(base, base*2); self.p2 = nn.MaxPool2d(2)
1056
- self.d3 = DoubleConv(base*2, base*4); self.p3 = nn.MaxPool2d(2)
1057
- self.d4 = DoubleConv(base*4, base*8); self.p4 = nn.MaxPool2d(2)
1058
- self.bn = DoubleConv(base*8, base*16)
1059
- self.u4 = nn.ConvTranspose2d(base*16, base*8, 2, 2); self.du4 = DoubleConv(base*16, base*8)
1060
- self.u3 = nn.ConvTranspose2d(base*8, base*4, 2, 2); self.du3 = DoubleConv(base*8, base*4)
1061
- self.u2 = nn.ConvTranspose2d(base*4, base*2, 2, 2); self.du2 = DoubleConv(base*4, base*2)
1062
- self.u1 = nn.ConvTranspose2d(base*2, base, 2, 2); self.du1 = DoubleConv(base*2, base)
1063
- self.out = nn.Conv2d(base, out_ch, 1)
1064
-
1065
- def forward(self, x):
1066
- d1=self.d1(x); p1=self.p1(d1)
1067
- d2=self.d2(p1); p2=self.p2(d2)
1068
- d3=self.d3(p2); p3=self.p3(d3)
1069
- d4=self.d4(p3); p4=self.p4(d4)
1070
- b=self.bn(p4)
1071
- x=self.u4(b); x=torch.cat([x,d4],1); x=self.du4(x)
1072
- x=self.u3(x); x=torch.cat([x,d3],1); x=self.du3(x)
1073
- x=self.u2(x); x=torch.cat([x,d2],1); x=self.du2(x)
1074
- x=self.u1(x); x=torch.cat([x,d1],1); x=self.du1(x)
1075
- return self.out(x)
1076
-
1077
-
1078
- @st.cache_resource
1079
- def load_model(device: str):
1080
- local = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILENAME,
1081
- force_download=True)
1082
- file_sha = sha256_file(local)
1083
- mtime = time.ctime(os.path.getmtime(local))
1084
- size_mb = os.path.getsize(local) / 1e6
1085
-
1086
- st.sidebar.markdown("### πŸ” Model debug")
1087
- st.sidebar.caption(f"Repo: `{MODEL_REPO_ID}`")
1088
- st.sidebar.caption(f"File: `{MODEL_FILENAME}`")
1089
- st.sidebar.caption(f"Size: {size_mb:.2f} MB")
1090
- st.sidebar.caption(f"Modified: {mtime}")
1091
- st.sidebar.caption(f"SHA256: `{file_sha[:20]}…`")
1092
-
1093
- ckpt = torch.load(local, map_location=device)
1094
- state = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt
1095
- model = UNet(in_ch=2, out_ch=2, base=32)
1096
- model.load_state_dict(state)
1097
- model.to(device).eval()
1098
- return model
1099
-
1100
-
1101
- # ─────────────────────────────────────────────────────────────────────────────
1102
- # PAGE CONFIG + CSS
1103
- # ─────────────────────────────────────────────────────────────────────────────
1104
-
1105
- st.set_page_config(page_title="MyoSight β€” Myotube Analyser",
1106
- layout="wide", page_icon="πŸ”¬")
1107
-
1108
- st.markdown("""
1109
- <style>
1110
- body, .stApp { background:#0e0e1a; color:#e0e0e0; }
1111
- .block-container { max-width:1200px; padding-top:1.25rem; }
1112
- h1,h2,h3,h4 { color:#90caf9; }
1113
- .flag-box {
1114
- background:#3e1a1a; border-left:4px solid #ef5350;
1115
- padding:10px 16px; border-radius:8px; margin:8px 0;
1116
- }
1117
- </style>
1118
- """, unsafe_allow_html=True)
1119
-
1120
- st.title("πŸ”¬ MyoSight β€” Myotube & Nuclei Analyser")
1121
- device = "cuda" if torch.cuda.is_available() else "cpu"
1122
-
1123
- # ─────────────────────────────────────────────────────────────────────────────
1124
- # SIDEBAR
1125
- # ─────────────────────────────────────────────────────────────────────────────
1126
- with st.sidebar:
1127
- st.caption(f"Device: **{device}**")
1128
-
1129
- st.header("Input mapping")
1130
- src1 = st.selectbox("Model channel 1 (MyHC / myotubes)",
1131
- ["Red", "Green", "Blue", "Grayscale"], index=0)
1132
- inv1 = st.checkbox("Invert channel 1", value=False)
1133
- src2 = st.selectbox("Model channel 2 (DAPI / nuclei)",
1134
- ["Red", "Green", "Blue", "Grayscale"], index=2)
1135
- inv2 = st.checkbox("Invert channel 2", value=False)
1136
-
1137
- st.header("Preprocessing")
1138
- image_size = st.select_slider("Model input size",
1139
- options=[256, 384, 512, 640, 768, 1024], value=512)
1140
-
1141
- st.header("Thresholds")
1142
- thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.45, 0.01)
1143
- thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.40, 0.01)
1144
-
1145
- st.header("Fusion Index method")
1146
- fi_method = st.radio(
1147
- "FI classification method",
1148
- ["Cytoplasm-hole (accurate, Lair 2025)", "Pixel-overlap (legacy)"],
1149
- index=0,
1150
- help=(
1151
- "Cytoplasm-hole: checks for a MyHC signal dip beneath each nucleus β€” "
1152
- "eliminates false positives from nuclei sitting above/below myotubes in Z. "
1153
- "Pixel-overlap: legacy method that overestimates FI (Lair et al. 2025)."
1154
- )
1155
- )
1156
- use_hole_method = fi_method.startswith("Cytoplasm")
1157
- hole_ratio_thr = st.slider(
1158
- "Hole ratio threshold", 0.50, 0.99, 0.85, 0.01,
1159
- help=(
1160
- "A nucleus is counted as fused if its MyHC signal is less than "
1161
- "this fraction of the surrounding cytoplasm ring signal. "
1162
- "Lower = stricter (fewer nuclei counted as fused). "
1163
- "0.85 is the value validated by Lair et al. 2025."
1164
- ),
1165
- disabled=not use_hole_method,
1166
- )
1167
- ring_width_px = st.number_input(
1168
- "Cytoplasm ring width (px)", 2, 20, 6, 1,
1169
- help="Width of the ring around each nucleus used to measure local MyHC intensity.",
1170
- disabled=not use_hole_method,
1171
- )
1172
-
1173
- st.header("Postprocessing")
1174
- min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
1175
- min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
1176
- nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
1177
- myo_open_radius = st.number_input("Myotube open radius", 0, 50, 2, 1,
1178
- help="Opening removes small noise without merging separate myotubes. "
1179
- "Replaces the old closing radius which was merging adjacent myotubes.")
1180
-
1181
- st.header("Myotube separation")
1182
- st.caption(
1183
- "These controls break apart touching/bridged myotubes that would "
1184
- "otherwise be counted as a single object."
1185
- )
1186
- myo_erode_radius = st.number_input(
1187
- "Myotube erode radius (px)", 0, 15, 2, 1,
1188
- help=(
1189
- "Erode + re-dilate breaks thin pixel bridges between adjacent "
1190
- "myotubes while preserving their overall size. "
1191
- "Start at 2 px; increase to 3–4 px for very dense cultures. "
1192
- "Set 0 to disable."
1193
- )
1194
- )
1195
- min_myo_aspect_ratio = st.number_input(
1196
- "Min myotube aspect ratio", 0.0, 10.0, 0.0, 0.1,
1197
- help=(
1198
- "Rejects round blobs (debris/artifacts) that are not real myotubes. "
1199
- "Myotubes are elongated (aspect ratio > 3). Round objects have ~1. "
1200
- "Set to 1.5–2.0 to filter false positives in sparse cultures. "
1201
- "Set to 0 to disable (default)."
1202
- )
1203
- )
1204
- myo_max_area_px = st.number_input(
1205
- "Max myotube area before split (pxΒ²)", 0, 500000, 20000, 500,
1206
- help=(
1207
- "Any connected myotube region larger than this is split using "
1208
- "nucleus-seeded watershed. Set to 0 to disable. "
1209
- "Increase for cultures with legitimately large single myotubes."
1210
- )
1211
- )
1212
- myo_split_min_seeds = st.number_input(
1213
- "Min nuclei seeds to split", 2, 20, 2, 1,
1214
- help=(
1215
- "Minimum nucleus centroids required before splitting a large region. "
1216
- "Set to 2 to split merged pairs; increase if single large myotubes "
1217
- "are being incorrectly split."
1218
- )
1219
- )
1220
-
1221
- st.header("Watershed (nuclei splitting)")
1222
- nuc_ws_min_dist = st.number_input("Min watershed distance", 1, 30, 3, 1)
1223
- nuc_ws_min_area = st.number_input("Min watershed area (px)", 1, 500, 6, 1)
1224
-
1225
- st.header("Overlay")
1226
- nuc_hex = st.color_picker("Nuclei colour", "#00FFFF")
1227
- myo_hex = st.color_picker("Myotube colour", "#FF0000")
1228
- alpha = st.slider("Overlay alpha", 0.0, 1.0, 0.45, 0.01)
1229
- nuc_rgb = hex_to_rgb(nuc_hex)
1230
- myo_rgb = hex_to_rgb(myo_hex)
1231
- label_nuc = st.checkbox("Show nucleus IDs on overlay", value=True)
1232
- label_myo = st.checkbox("Show myotube IDs on overlay", value=True)
1233
-
1234
- st.header("Surface area")
1235
- px_um = st.number_input("Pixel size (Β΅m) β€” set for real Β΅mΒ²",
1236
- value=1.0, min_value=0.01, step=0.01)
1237
-
1238
- st.header("Active learning")
1239
- enable_al = st.toggle("Enable correction upload", value=True)
1240
-
1241
- st.header("Metric definitions")
1242
- with st.expander("Fusion Index"):
1243
- st.write("100 Γ— (nuclei in myotubes with β‰₯2 nuclei) / total nuclei")
1244
- with st.expander("MyHC-positive nucleus"):
1245
- st.write("Counted if β‰₯10% of nucleus pixels overlap a myotube.")
1246
- with st.expander("Surface area"):
1247
- st.write("Pixel count Γ— px_umΒ². Set pixel size for real Β΅mΒ² values.")
1248
-
1249
-
1250
- # ─────────────────────────────────────────────────────────────────────────────
1251
- # FILE UPLOADER
1252
- # ─────────────────────────────────────────────────────────────────────────────
1253
- uploads = st.file_uploader(
1254
- "Upload 1+ images (png / jpg / tif). Public Space β€” don't upload sensitive data.",
1255
- type=["png", "jpg", "jpeg", "tif", "tiff"],
1256
- accept_multiple_files=True,
1257
- )
1258
-
1259
- for key in ("df", "artifacts", "zip_bytes", "bio_metrics"):
1260
- if key not in st.session_state:
1261
- st.session_state[key] = None
1262
-
1263
- if not uploads:
1264
- st.info("πŸ‘† Upload one or more fluorescence images to get started.")
1265
- st.stop()
1266
-
1267
- model = load_model(device=device)
1268
-
1269
- # ─────────────────────────────────────────────────────────────────────────────
1270
- # RUN ANALYSIS
1271
- # ─────────────────────────────────────────────────────────────────────────────
1272
- with st.form("run_form"):
1273
- run = st.form_submit_button("β–Ά Run / Rerun analysis", type="primary")
1274
-
1275
- if run:
1276
- results = []
1277
- artifacts = {}
1278
- all_bio_metrics = {}
1279
- low_conf_flags = []
1280
- zip_buf = io.BytesIO()
1281
-
1282
- with st.spinner("Analysing images…"):
1283
- with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1284
- prog = st.progress(0.0)
1285
-
1286
- for i, up in enumerate(uploads):
1287
- name = Path(up.name).stem
1288
- rgb_u8 = np.array(
1289
- Image.open(io.BytesIO(up.getvalue())).convert("RGB"),
1290
- dtype=np.uint8
1291
- )
1292
-
1293
- ch1 = get_channel(rgb_u8, src1) # MyHC channel
1294
- ch2 = get_channel(rgb_u8, src2) # DAPI / nuclei channel
1295
- if inv1: ch1 = 255 - ch1
1296
- if inv2: ch2 = 255 - ch2
1297
-
1298
- # Keep the full-resolution MyHC channel for the cytoplasm-hole
1299
- # FI classifier β€” must be at original image resolution
1300
- myc_full = ch1.copy() # uint8, original resolution
1301
-
1302
- H = W = int(image_size)
1303
- x1 = resize_u8_to_float01(ch1, W, H, Image.BILINEAR)
1304
- x2 = resize_u8_to_float01(ch2, W, H, Image.BILINEAR)
1305
- x = np.stack([x1, x2], 0).astype(np.float32)
1306
-
1307
- x_t = torch.from_numpy(x).unsqueeze(0).to(device)
1308
- with torch.no_grad():
1309
- probs = torch.sigmoid(model(x_t)).cpu().numpy()[0]
1310
-
1311
- # Confidence check
1312
- conf = float(np.mean([probs[0].max(), probs[1].max()]))
1313
- if conf < CONF_FLAG_THR:
1314
- low_conf_flags.append((name, conf))
1315
- add_to_queue(rgb_u8, reason="low_confidence",
1316
- metadata={"confidence": conf, "filename": up.name})
1317
-
1318
- nuc_raw = (probs[0] > float(thr_nuc)).astype(np.uint8)
1319
- myo_raw = (probs[1] > float(thr_myo)).astype(np.uint8)
1320
-
1321
- nuc_pp, myo_pp = postprocess_masks(
1322
- nuc_raw, myo_raw,
1323
- min_nuc_area=int(min_nuc_area),
1324
- min_myo_area=int(min_myo_area),
1325
- nuc_close_radius=int(nuc_close_radius),
1326
- myo_open_radius=int(myo_open_radius),
1327
- myo_erode_radius=int(myo_erode_radius),
1328
- min_myo_aspect_ratio=float(min_myo_aspect_ratio),
1329
- )
1330
-
1331
- # Flat overlay for ZIP (no labels β€” just colour regions)
1332
- simple_ov = make_simple_overlay(
1333
- rgb_u8, nuc_pp, myo_pp, nuc_rgb, myo_rgb, float(alpha)
1334
- )
1335
-
1336
- # Label maps β€” shared across all three viewers
1337
- nuc_lab = label_nuclei_watershed(nuc_pp,
1338
- min_distance=int(nuc_ws_min_dist),
1339
- min_nuc_area=int(nuc_ws_min_area))
1340
- myo_lab = label_cc(myo_pp)
1341
-
1342
- # Fix 2+3: split oversized merged myotube regions using nucleus seeds
1343
- # Runs only when myo_max_area_px > 0; no effect if disabled
1344
- if int(myo_max_area_px) > 0:
1345
- myo_lab = split_large_myotubes(
1346
- myo_lab, nuc_lab,
1347
- max_area_px=int(myo_max_area_px),
1348
- min_seeds=int(myo_split_min_seeds),
1349
- )
1350
-
1351
- # Coloured pixel overlays (no baked-in text β€” labels drawn as SVG)
1352
- inst_px = make_coloured_overlay(rgb_u8, nuc_lab, myo_lab, alpha=float(alpha))
1353
- nuc_only_px = make_coloured_overlay(rgb_u8, nuc_lab, np.zeros_like(myo_lab), alpha=float(alpha))
1354
- myo_only_px = make_coloured_overlay(rgb_u8, np.zeros_like(nuc_lab), myo_lab, alpha=float(alpha))
1355
-
1356
- # Label positions in image-pixel coordinates (used by SVG viewer)
1357
- orig_h_img, orig_w_img = rgb_u8.shape[:2]
1358
- label_positions = collect_label_positions(nuc_lab, myo_lab, orig_w_img, orig_h_img)
1359
-
1360
- bio = compute_bio_metrics(
1361
- nuc_pp, myo_pp,
1362
- myc_channel_full=myc_full if use_hole_method else None,
1363
- nuc_ws_min_distance=int(nuc_ws_min_dist),
1364
- nuc_ws_min_area=int(nuc_ws_min_area),
1365
- px_um=float(px_um),
1366
- ring_width=int(ring_width_px),
1367
- hole_ratio_thr=float(hole_ratio_thr),
1368
- )
1369
- bio["fi_method"] = "cytoplasm-hole" if use_hole_method else "pixel-overlap"
1370
- per_areas = bio.pop("_per_myotube_areas", [])
1371
- bio["image"] = name
1372
- results.append(bio)
1373
- all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
1374
-
1375
- artifacts[name] = {
1376
- # raw pixel data β€” overlays built at display time from these
1377
- "rgb_u8" : rgb_u8,
1378
- "nuc_lab" : nuc_lab,
1379
- "myo_lab" : myo_lab,
1380
- # postprocessed masks (for outline generation)
1381
- "nuc_pp_arr" : nuc_pp,
1382
- "myo_pp_arr" : myo_pp,
1383
- # static mask PNGs
1384
- "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
1385
- "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
1386
- "nuc_raw_bytes" : png_bytes((nuc_raw*255).astype(np.uint8)),
1387
- "myo_raw_bytes" : png_bytes((myo_raw*255).astype(np.uint8)),
1388
- # label positions for SVG viewer
1389
- "label_positions": label_positions,
1390
- # image dimensions
1391
- "img_w" : orig_w_img,
1392
- "img_h" : orig_h_img,
1393
- }
1394
-
1395
- # ZIP built with current colour settings at run time
1396
- outline_ov = make_outline_overlay(rgb_u8, nuc_lab, myo_lab,
1397
- nuc_color=nuc_rgb, myo_color=(0, 255, 0),
1398
- line_width=2)
1399
- zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
1400
- zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
1401
- zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
1402
- zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
1403
- zf.writestr(f"{name}/overlay_outlines.png", png_bytes(outline_ov))
1404
- zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
1405
- zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
1406
- zf.writestr(f"{name}/nuclei_raw.png", artifacts[name]["nuc_raw_bytes"])
1407
- zf.writestr(f"{name}/myotube_raw.png", artifacts[name]["myo_raw_bytes"])
1408
-
1409
- prog.progress((i + 1) / len(uploads))
1410
-
1411
- df = pd.DataFrame(results).sort_values("image")
1412
- zf.writestr("metrics.csv", df.to_csv(index=False).encode("utf-8"))
1413
-
1414
- st.session_state.df = df
1415
- st.session_state.artifacts = artifacts
1416
- st.session_state.zip_bytes = zip_buf.getvalue()
1417
- st.session_state.bio_metrics = all_bio_metrics
1418
-
1419
- if low_conf_flags:
1420
- names_str = ", ".join(f"{n} (conf={c:.2f})" for n, c in low_conf_flags)
1421
- st.markdown(
1422
- f"<div class='flag-box'>⚠️ <b>Low-confidence images auto-queued for retraining:</b> "
1423
- f"{names_str}</div>",
1424
- unsafe_allow_html=True,
1425
- )
1426
-
1427
- if st.session_state.df is None:
1428
- st.info("Click **β–Ά Run / Rerun analysis** to generate results.")
1429
- st.stop()
1430
-
1431
- # ─────────────────────────────────────────────────────────────────────────────
1432
- # RESULTS TABLE + DOWNLOADS
1433
- # ─────────────────────────────────────────────────────────────────────────────
1434
- st.subheader("πŸ“‹ Results")
1435
- display_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
1436
- st.dataframe(st.session_state.df[display_cols], use_container_width=True, height=320)
1437
-
1438
- c1, c2, c3 = st.columns(3)
1439
- with c1:
1440
- st.download_button("⬇️ Download metrics.csv",
1441
- st.session_state.df[display_cols].to_csv(index=False).encode(),
1442
- file_name="metrics.csv", mime="text/csv")
1443
- with c2:
1444
- st.download_button("⬇️ Download results.zip",
1445
- st.session_state.zip_bytes,
1446
- file_name="results.zip", mime="application/zip")
1447
- with c3:
1448
- # Rebuild ZIP with CURRENT colour / alpha settings β€” no model rerun needed
1449
- if st.button("🎨 Rebuild ZIP with current colours", help=(
1450
- "Regenerates the overlay images in the ZIP using the current "
1451
- "colour picker and alpha values from the sidebar."
1452
- )):
1453
- import base64 as _b64_zip
1454
- new_zip_buf = io.BytesIO()
1455
- with zipfile.ZipFile(new_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1456
- for img_name, art in st.session_state.artifacts.items():
1457
- _r = art["rgb_u8"]
1458
- _nl = art["nuc_lab"]
1459
- _ml = art["myo_lab"]
1460
- _zn = np.zeros_like(_nl)
1461
- _zm = np.zeros_like(_ml)
1462
- ov_comb = make_coloured_overlay(_r, _nl, _ml,
1463
- alpha=float(alpha),
1464
- nuc_color=nuc_rgb, myo_color=myo_rgb)
1465
- ov_nuc = make_coloured_overlay(_r, _nl, _zm,
1466
- alpha=float(alpha),
1467
- nuc_color=nuc_rgb, myo_color=None)
1468
- ov_myo = make_coloured_overlay(_r, _zn, _ml,
1469
- alpha=float(alpha),
1470
- nuc_color=None, myo_color=myo_rgb)
1471
- simple = make_simple_overlay(_r,
1472
- (_nl > 0).astype(np.uint8),
1473
- (_ml > 0).astype(np.uint8),
1474
- nuc_rgb, myo_rgb, float(alpha))
1475
- outline = make_outline_overlay(_r, _nl, _ml,
1476
- nuc_color=nuc_rgb, myo_color=(0, 255, 0),
1477
- line_width=2)
1478
- zf.writestr(f"{img_name}/overlay_combined.png", png_bytes(simple))
1479
- zf.writestr(f"{img_name}/overlay_instance.png", png_bytes(ov_comb))
1480
- zf.writestr(f"{img_name}/overlay_nuclei.png", png_bytes(ov_nuc))
1481
- zf.writestr(f"{img_name}/overlay_myotubes.png", png_bytes(ov_myo))
1482
- zf.writestr(f"{img_name}/overlay_outlines.png", png_bytes(outline))
1483
- zf.writestr(f"{img_name}/nuclei_pp.png", art["nuc_pp"])
1484
- zf.writestr(f"{img_name}/myotube_pp.png", art["myo_pp"])
1485
- zf.writestr(f"{img_name}/nuclei_raw.png", art["nuc_raw_bytes"])
1486
- zf.writestr(f"{img_name}/myotube_raw.png", art["myo_raw_bytes"])
1487
- df_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
1488
- zf.writestr("metrics.csv", st.session_state.df[df_cols].to_csv(index=False).encode())
1489
- st.session_state.zip_bytes = new_zip_buf.getvalue()
1490
- st.success("ZIP rebuilt with current colours. Click Download results.zip above to save.")
1491
-
1492
- st.divider()
1493
-
1494
- # ─────────────────────────────────────────────────────────────────────────────
1495
- # PER-IMAGE PREVIEW + ANIMATED METRICS
1496
- # ─────────────────────────────────────────────────────────────────────────────
1497
- st.subheader("πŸ–ΌοΈ Image preview & live metrics")
1498
- names = list(st.session_state.artifacts.keys())
1499
- pick = st.selectbox("Select image", names)
1500
-
1501
- col_img, col_metrics = st.columns([3, 2], gap="large")
1502
-
1503
- with col_img:
1504
- tabs = st.tabs([
1505
- "πŸ”΅ Combined",
1506
- "πŸ“ Outlines",
1507
- "🟣 Nuclei only",
1508
- "🟠 Myotubes only",
1509
- "πŸ“· Original",
1510
- "⬜ Nuclei mask",
1511
- "⬜ Myotube mask",
1512
- ])
1513
- art = st.session_state.artifacts[pick]
1514
- bio_cur = st.session_state.bio_metrics.get(pick, {})
1515
- lpos = art["label_positions"]
1516
- iw = art["img_w"]
1517
- ih = art["img_h"]
1518
-
1519
- # Build coloured overlays RIGHT NOW using the current sidebar colour / alpha.
1520
- # This means changing colour picker or alpha slider instantly updates the
1521
- # viewer β€” no rerun needed for display changes.
1522
- import base64 as _b64_disp
1523
- def _b64png_disp(arr):
1524
- return _b64_disp.b64encode(png_bytes(arr)).decode()
1525
-
1526
- _rgb = art["rgb_u8"]
1527
- _nl = art["nuc_lab"]
1528
- _ml = art["myo_lab"]
1529
- _zero_nuc = np.zeros_like(_nl)
1530
- _zero_myo = np.zeros_like(_ml)
1531
-
1532
- inst_b64 = _b64png_disp(make_coloured_overlay(_rgb, _nl, _ml,
1533
- alpha=float(alpha),
1534
- nuc_color=nuc_rgb, myo_color=myo_rgb))
1535
- nuc_only_b64 = _b64png_disp(make_coloured_overlay(_rgb, _nl, _zero_myo,
1536
- alpha=float(alpha),
1537
- nuc_color=nuc_rgb, myo_color=None))
1538
- myo_only_b64 = _b64png_disp(make_coloured_overlay(_rgb, _zero_nuc, _ml,
1539
- alpha=float(alpha),
1540
- nuc_color=None, myo_color=myo_rgb))
1541
-
1542
- with tabs[0]:
1543
- html_combined = make_svg_viewer(
1544
- inst_b64, iw, ih, lpos,
1545
- show_nuclei=True, show_myotubes=True,
1546
- )
1547
- st.components.v1.html(html_combined, height=680, scrolling=False)
1548
-
1549
- with tabs[1]:
1550
- # Outline overlay β€” shows contour boundaries around each detection
1551
- outline_img = make_outline_overlay(
1552
- _rgb, _nl, _ml,
1553
- nuc_color=nuc_rgb, myo_color=(0, 255, 0),
1554
- line_width=2,
1555
- )
1556
- outline_b64 = _b64png_disp(outline_img)
1557
- outline_lpos = lpos # show both labels on outline view
1558
- html_outline = make_svg_viewer(
1559
- outline_b64, iw, ih, outline_lpos,
1560
- show_nuclei=label_nuc, show_myotubes=label_myo,
1561
- )
1562
- st.components.v1.html(html_outline, height=680, scrolling=False)
1563
-
1564
- with tabs[2]:
1565
- nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
1566
- html_nuc = make_svg_viewer(
1567
- nuc_only_b64, iw, ih, nuc_only_lpos,
1568
- show_nuclei=True, show_myotubes=False,
1569
- )
1570
- st.components.v1.html(html_nuc, height=680, scrolling=False)
1571
-
1572
- with tabs[3]:
1573
- myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
1574
- html_myo = make_svg_viewer(
1575
- myo_only_b64, iw, ih, myo_only_lpos,
1576
- show_nuclei=False, show_myotubes=True,
1577
- )
1578
- st.components.v1.html(html_myo, height=680, scrolling=False)
1579
-
1580
- with tabs[4]:
1581
- st.image(art["rgb_u8"], use_container_width=True)
1582
- with tabs[5]:
1583
- st.image(art["nuc_pp"], use_container_width=True)
1584
- with tabs[6]:
1585
- st.image(art["myo_pp"], use_container_width=True)
1586
-
1587
- with col_metrics:
1588
- st.markdown("#### πŸ“Š Live metrics")
1589
- bio = st.session_state.bio_metrics.get(pick, {})
1590
- per_areas = bio.get("_per_myotube_areas", [])
1591
-
1592
- r1c1, r1c2, r1c3 = st.columns(3)
1593
- r2c1, r2c2, r2c3 = st.columns(3)
1594
- r3c1, r3c2, r3c3 = st.columns(3)
1595
-
1596
- placeholders = {
1597
- "total_nuclei" : r1c1.empty(),
1598
- "myotube_count" : r1c2.empty(),
1599
- "myHC_positive_nuclei" : r1c3.empty(),
1600
- "myHC_positive_percentage": r2c1.empty(),
1601
- "fusion_index" : r2c2.empty(),
1602
- "avg_nuclei_per_myotube" : r2c3.empty(),
1603
- "total_area_um2" : r3c1.empty(),
1604
- "mean_area_um2" : r3c2.empty(),
1605
- "max_area_um2" : r3c3.empty(),
1606
- }
1607
-
1608
- specs = [
1609
- ("total_nuclei", "Total nuclei", "#4fc3f7", False),
1610
- ("myotube_count", "Myotubes", "#ff8a65", False),
1611
- ("myHC_positive_nuclei", "MyHC⁺ nuclei", "#a5d6a7", False),
1612
- ("myHC_positive_percentage", "MyHC⁺ %", "#ce93d8", True),
1613
- ("fusion_index", "Fusion index %", "#80cbc4", True),
1614
- ("avg_nuclei_per_myotube", "Avg nuc/myotube", "#80deea", True),
1615
- ("total_area_um2", f"Total area (Β΅mΒ²)", "#fff176", True),
1616
- ("mean_area_um2", f"Mean area (Β΅mΒ²)", "#ffcc80", True),
1617
- ("max_area_um2", f"Max area (Β΅mΒ²)", "#ef9a9a", True),
1618
- ]
1619
-
1620
- for key, label, color, is_float in specs:
1621
- val = bio.get(key, 0)
1622
- animated_metric(placeholders[key], label,
1623
- float(val) if is_float else int(val),
1624
- color=color)
1625
-
1626
- if per_areas:
1627
- st.markdown("#### πŸ“ Per-myotube area")
1628
- area_df = pd.DataFrame({
1629
- "Myotube" : [f"M{i+1}" for i in range(len(per_areas))],
1630
- f"Area (Β΅mΒ²)" : per_areas,
1631
- }).set_index("Myotube")
1632
- st.bar_chart(area_df, height=220)
1633
-
1634
- st.divider()
1635
-
1636
- # ─────────────────────────────────────────────────────────────────────────────
1637
- # ACTIVE LEARNING β€” CORRECTION UPLOAD
1638
- # ─────────────────────────────────────────────────────────────────────────────
1639
- if enable_al:
1640
- st.subheader("🧠 Submit corrected labels (Active Learning)")
1641
- st.caption(
1642
- "Upload corrected binary masks for any image. "
1643
- "Corrections are saved to corrections/ and picked up "
1644
- "automatically by self_train.py at the next trigger check."
1645
- )
1646
-
1647
- al_pick = st.selectbox("Correct masks for image", names, key="al_pick")
1648
- acol1, acol2 = st.columns(2)
1649
- with acol1:
1650
- corr_nuc = st.file_uploader("Corrected NUCLEI mask (PNG/TIF, binary 0/255)",
1651
- type=["png", "tif", "tiff"], key="nuc_corr")
1652
- with acol2:
1653
- corr_myo = st.file_uploader("Corrected MYOTUBE mask (PNG/TIF, binary 0/255)",
1654
- type=["png", "tif", "tiff"], key="myo_corr")
1655
-
1656
- if st.button("βœ… Submit corrections", type="primary"):
1657
- if corr_nuc is None or corr_myo is None:
1658
- st.error("Please upload BOTH a nuclei mask and a myotube mask.")
1659
- else:
1660
- orig_rgb = st.session_state.artifacts[al_pick]["rgb_u8"]
1661
- nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
1662
- myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
1663
- add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
1664
- reason="user_correction",
1665
- metadata={"source_image": al_pick,
1666
- "timestamp": datetime.now().isoformat()})
1667
- st.success(
1668
- f"βœ… Corrections for **{al_pick}** saved to `corrections/`. "
1669
- "The model will retrain at the next scheduled cycle."
1670
- )
1671
-
1672
- st.divider()
1673
-
1674
- # ─────────────────────────────────────────────────────────────────────────────
1675
- # RETRAINING QUEUE STATUS
1676
- # ─────────────────────────────────────────────────────────────────────────────
1677
- with st.expander("πŸ”§ Self-training queue status"):
1678
- _ensure_dirs()
1679
- q_items = list(QUEUE_DIR.glob("*.json"))
1680
- c_items = list(CORRECTIONS_DIR.glob("*/meta.json"))
1681
-
1682
- sq1, sq2 = st.columns(2)
1683
- sq1.metric("Images in retraining queue", len(q_items))
1684
- sq2.metric("Corrected label pairs", len(c_items))
1685
-
1686
- if q_items:
1687
- reasons = {}
1688
- for p in q_items:
1689
- try:
1690
- r = json.loads(p.read_text()).get("reason", "unknown")
1691
- reasons[r] = reasons.get(r, 0) + 1
1692
- except Exception:
1693
- pass
1694
- st.write("Queue breakdown:", reasons)
1695
-
1696
- manifest = Path("manifest.json")
1697
- if manifest.exists():
1698
- try:
1699
- history = json.loads(manifest.read_text())
1700
- if history:
1701
- st.markdown("**Last 5 retraining runs:**")
1702
- hist_df = pd.DataFrame(history[-5:])
1703
- st.dataframe(hist_df, use_container_width=True)
1704
- except Exception:
1705
- pass
1706
-
1707
- if st.button("πŸ”„ Trigger retraining now"):
1708
- import subprocess
1709
- subprocess.Popen(["python", "self_train.py", "--manual"])
1710
- st.info("Retraining started in the background. Check terminal / logs for progress.")