skarugu commited on
Commit
48afd21
Β·
1 Parent(s): 253e4e9

Update self_train to v2 and Streamlit app v8

Browse files
Files changed (2) hide show
  1. self_train.py +2 -2
  2. streamlit_app_v8.py +1710 -0
self_train.py CHANGED
@@ -222,7 +222,7 @@ class _FTDataset(Dataset):
222
  def __len__(self): return len(self.samples)
223
 
224
  def __getitem__(self, idx):
225
- ip, np_, mp = self.samples[idx]
226
  rgb = np.array(Image.open(ip).convert("RGB"), dtype=np.uint8)
227
  H = W = self.size
228
 
@@ -231,7 +231,7 @@ class _FTDataset(Dataset):
231
 
232
  red = _ch(rgb[..., 0])
233
  blue = _ch(rgb[..., 2])
234
- yn = _mk(np_)
235
  ym = _mk(mp)
236
 
237
  if self.augment:
 
222
  def __len__(self): return len(self.samples)
223
 
224
  def __getitem__(self, idx):
225
+ ip, nuc_path, mp = self.samples[idx]
226
  rgb = np.array(Image.open(ip).convert("RGB"), dtype=np.uint8)
227
  H = W = self.size
228
 
 
231
 
232
  red = _ch(rgb[..., 0])
233
  blue = _ch(rgb[..., 2])
234
+ yn = _mk(nuc_path)
235
  ym = _mk(mp)
236
 
237
  if self.augment:
streamlit_app_v8.py ADDED
@@ -0,0 +1,1710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.")