skarugu commited on
Commit
0fee17d
·
1 Parent(s): 248349c

Update app to V6

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +290 -43
src/streamlit_app.py CHANGED
@@ -39,7 +39,7 @@ import matplotlib.patches as mpatches
39
  from huggingface_hub import hf_hub_download
40
 
41
  import scipy.ndimage as ndi
42
- from skimage.morphology import remove_small_objects, disk, closing, opening
43
  from skimage import measure
44
  from skimage.segmentation import watershed
45
  from skimage.feature import peak_local_max
@@ -161,34 +161,170 @@ def compute_surface_area(myo_mask: np.ndarray, px_um: float = 1.0) -> dict:
161
  }
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  # ─────────────────────────────────────────────────────────────────────────────
165
  # Biological metrics (counting + fusion + surface area)
166
  # ─────────────────────────────────────────────────────────────────────────────
167
 
168
  def compute_bio_metrics(nuc_mask, myo_mask,
169
- min_overlap_frac=0.1,
 
170
  nuc_ws_min_distance=3,
171
  nuc_ws_min_area=6,
172
- px_um=1.0) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  nuc_lab = label_nuclei_watershed(nuc_mask,
174
  min_distance=nuc_ws_min_distance,
175
  min_nuc_area=nuc_ws_min_area)
176
  myo_lab = label_cc(myo_mask)
177
  total = int(nuc_lab.max())
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  pos, nm = 0, {}
180
- for prop in measure.regionprops(nuc_lab):
181
- coords = prop.coords
182
- ids = myo_lab[coords[:, 0], coords[:, 1]]
183
- ids = ids[ids > 0]
184
- if ids.size == 0:
185
- continue
186
- unique, counts = np.unique(ids, return_counts=True)
187
- mt = int(unique[np.argmax(counts)])
188
- frac = counts.max() / len(coords)
189
- if frac >= min_overlap_frac:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  pos += 1
191
- nm.setdefault(mt, []).append(prop.label)
192
 
193
  per = [len(v) for v in nm.values()]
194
  fused = sum(n for n in per if n >= 2)
@@ -209,7 +345,7 @@ def compute_bio_metrics(nuc_mask, myo_mask,
209
  "total_area_um2" : sa["total_area_um2"],
210
  "mean_area_um2" : sa["mean_area_um2"],
211
  "max_area_um2" : sa["max_area_um2"],
212
- "_per_myotube_areas" : sa["per_myotube_areas"], # _ prefix = kept out of CSV
213
  }
214
 
215
 
@@ -235,12 +371,17 @@ def make_simple_overlay(rgb_u8, nuc_mask, myo_mask, nuc_color, myo_color, alpha)
235
  def make_coloured_overlay(rgb_u8: np.ndarray,
236
  nuc_lab: np.ndarray,
237
  myo_lab: np.ndarray,
238
- alpha: float = 0.45) -> np.ndarray:
 
 
239
  """
240
  Colour the mask regions only — NO text baked in.
241
  Returns an RGB uint8 array at original image resolution.
242
- Text labels are rendered separately as SVG so they stay
243
- perfectly sharp at any zoom level.
 
 
 
244
  """
245
  orig_h, orig_w = rgb_u8.shape[:2]
246
  nuc_cmap = plt.cm.get_cmap("cool")
@@ -258,15 +399,24 @@ def make_coloured_overlay(rgb_u8: np.ndarray,
258
 
259
  base = rgb_u8.astype(np.float32).copy()
260
  if n_myo > 0:
261
- myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
262
- myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
263
  mask = myo_disp > 0
264
- base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
 
 
 
 
 
 
 
265
  if n_nuc > 0:
266
- nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
267
- nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
268
  mask = nuc_disp > 0
269
- base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
 
 
 
 
 
 
270
 
271
  return np.clip(base, 0, 255).astype(np.uint8)
272
 
@@ -819,6 +969,34 @@ with st.sidebar:
819
  thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.50, 0.01)
820
  thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.50, 0.01)
821
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  st.header("Postprocessing")
823
  min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
824
  min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
@@ -897,11 +1075,15 @@ if run:
897
  dtype=np.uint8
898
  )
899
 
900
- ch1 = get_channel(rgb_u8, src1)
901
- ch2 = get_channel(rgb_u8, src2)
902
  if inv1: ch1 = 255 - ch1
903
  if inv2: ch2 = 255 - ch2
904
 
 
 
 
 
905
  H = W = int(image_size)
906
  x1 = resize_u8_to_float01(ch1, W, H, Image.BILINEAR)
907
  x2 = resize_u8_to_float01(ch2, W, H, Image.BILINEAR)
@@ -951,27 +1133,29 @@ if run:
951
 
952
  bio = compute_bio_metrics(
953
  nuc_pp, myo_pp,
 
954
  nuc_ws_min_distance=int(nuc_ws_min_dist),
955
  nuc_ws_min_area=int(nuc_ws_min_area),
956
  px_um=float(px_um),
 
 
957
  )
 
958
  per_areas = bio.pop("_per_myotube_areas", [])
959
  bio["image"] = name
960
  results.append(bio)
961
  all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
962
 
963
- import base64 as _b64
964
- def _b64png(arr): return _b64.b64encode(png_bytes(arr)).decode()
965
-
966
  artifacts[name] = {
967
- # raw bytes for static display / ZIP
968
- "original" : png_bytes(rgb_u8),
 
 
 
969
  "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
970
  "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
971
- # base64 pixel images for SVG viewer (no text baked in)
972
- "inst_b64" : _b64png(inst_px),
973
- "nuc_only_b64" : _b64png(nuc_only_px),
974
- "myo_only_b64" : _b64png(myo_only_px),
975
  # label positions for SVG viewer
976
  "label_positions": label_positions,
977
  # image dimensions
@@ -979,15 +1163,15 @@ if run:
979
  "img_h" : orig_h_img,
980
  }
981
 
982
- # ZIP flat colour PNGs (no text labels, clean for downstream use)
983
  zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
984
  zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
985
  zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
986
  zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
987
  zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
988
  zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
989
- zf.writestr(f"{name}/nuclei_raw.png", png_bytes((nuc_raw*255).astype(np.uint8)))
990
- zf.writestr(f"{name}/myotube_raw.png", png_bytes((myo_raw*255).astype(np.uint8)))
991
 
992
  prog.progress((i + 1) / len(uploads))
993
 
@@ -1018,7 +1202,7 @@ st.subheader("📋 Results")
1018
  display_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
1019
  st.dataframe(st.session_state.df[display_cols], use_container_width=True, height=320)
1020
 
1021
- c1, c2 = st.columns(2)
1022
  with c1:
1023
  st.download_button("⬇️ Download metrics.csv",
1024
  st.session_state.df[display_cols].to_csv(index=False).encode(),
@@ -1027,6 +1211,46 @@ with c2:
1027
  st.download_button("⬇️ Download results.zip",
1028
  st.session_state.zip_bytes,
1029
  file_name="results.zip", mime="application/zip")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
 
1031
  st.divider()
1032
 
@@ -1054,9 +1278,32 @@ with col_img:
1054
  iw = art["img_w"]
1055
  ih = art["img_h"]
1056
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  with tabs[0]:
1058
  html_combined = make_svg_viewer(
1059
- art["inst_b64"], iw, ih, lpos,
1060
  show_nuclei=True, show_myotubes=True,
1061
  )
1062
  st.components.v1.html(html_combined, height=680, scrolling=False)
@@ -1064,7 +1311,7 @@ with col_img:
1064
  with tabs[1]:
1065
  nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
1066
  html_nuc = make_svg_viewer(
1067
- art["nuc_only_b64"], iw, ih, nuc_only_lpos,
1068
  show_nuclei=True, show_myotubes=False,
1069
  )
1070
  st.components.v1.html(html_nuc, height=680, scrolling=False)
@@ -1072,13 +1319,13 @@ with col_img:
1072
  with tabs[2]:
1073
  myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
1074
  html_myo = make_svg_viewer(
1075
- art["myo_only_b64"], iw, ih, myo_only_lpos,
1076
  show_nuclei=False, show_myotubes=True,
1077
  )
1078
  st.components.v1.html(html_myo, height=680, scrolling=False)
1079
 
1080
  with tabs[3]:
1081
- st.image(art["original"], use_container_width=True)
1082
  with tabs[4]:
1083
  st.image(art["nuc_pp"], use_container_width=True)
1084
  with tabs[5]:
 
39
  from huggingface_hub import hf_hub_download
40
 
41
  import scipy.ndimage as ndi
42
+ from skimage.morphology import remove_small_objects, disk, closing, opening, binary_dilation
43
  from skimage import measure
44
  from skimage.segmentation import watershed
45
  from skimage.feature import peak_local_max
 
161
  }
162
 
163
 
164
+ # ─────────────────────────────────────────────────────────────────────────────
165
+ # Cytoplasm-hole nucleus classifier (MyoFuse method, Lair et al. 2025)
166
+ # ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ def classify_nucleus_in_myotube(nuc_coords: np.ndarray,
169
+ myc_channel: np.ndarray,
170
+ myo_mask_full: np.ndarray,
171
+ ring_width: int = 6,
172
+ hole_ratio_thr: float = 0.85) -> bool:
173
+ """
174
+ Determine whether a nucleus is GENUINELY inside a myotube
175
+ using the cytoplasm-hole method (MyoFuse, Lair et al. 2025).
176
+
177
+ A fused nucleus inside a myotube physically displaces the cytoplasm,
178
+ creating a local dip (dark "hole") in the MyHC signal beneath it.
179
+ An unfused nucleus sitting on top of a myotube in Z does NOT create
180
+ this dip — its underlying MyHC signal stays bright.
181
+
182
+ Algorithm
183
+ ---------
184
+ 1. Check the nucleus pixel footprint overlaps the myotube mask at all.
185
+ If not — definitely not fused.
186
+ 2. Measure mean MyHC intensity under the nucleus pixels (I_nuc).
187
+ 3. Build a ring around the nucleus (dilated - eroded footprint) clipped
188
+ to the myotube mask — this is the local cytoplasm reference (I_ring).
189
+ 4. Compute hole_ratio = I_nuc / I_ring.
190
+ If hole_ratio < hole_ratio_thr → nucleus has created a cytoplasmic
191
+ hole → genuinely fused.
192
+ If hole_ratio ≥ hole_ratio_thr → nucleus sits on top in Z → not fused.
193
+
194
+ Parameters
195
+ ----------
196
+ nuc_coords : (N,2) array of (row, col) pixel coords for this nucleus
197
+ myc_channel : 2D float32 array of MyHC channel at FULL image resolution
198
+ myo_mask_full : 2D binary mask of myotubes at FULL image resolution
199
+ ring_width : dilation radius (px) for the cytoplasm ring
200
+ hole_ratio_thr: threshold below which the nucleus is counted as fused
201
+ (default 0.85, consistent with MyoFuse calibration)
202
+
203
+ Returns
204
+ -------
205
+ True if nucleus is genuinely fused (inside myotube cytoplasm)
206
+ """
207
+ rows, cols = nuc_coords[:, 0], nuc_coords[:, 1]
208
+ H, W = myc_channel.shape
209
+
210
+ # Step 1 — must overlap myotube mask at all
211
+ in_myo = myo_mask_full[rows, cols]
212
+ if in_myo.sum() == 0:
213
+ return False
214
+
215
+ # Step 2 — mean MyHC under nucleus
216
+ I_nuc = float(myc_channel[rows, cols].mean())
217
+
218
+ # Step 3 — build ring around nucleus footprint, clipped to myotube mask
219
+ nuc_footprint = np.zeros((H, W), dtype=bool)
220
+ nuc_footprint[rows, cols] = True
221
+
222
+ nuc_dilated = binary_dilation(nuc_footprint, footprint=disk(ring_width))
223
+ ring_mask = nuc_dilated & ~nuc_footprint & myo_mask_full.astype(bool)
224
+
225
+ if ring_mask.sum() < 4:
226
+ # Ring too small (nucleus near edge of myotube) — fall back to overlap test
227
+ return in_myo.mean() >= 0.10
228
+
229
+ I_ring = float(myc_channel[ring_mask].mean())
230
+
231
+ if I_ring < 1e-6:
232
+ # No myotube signal at all in ring — something is wrong, use overlap
233
+ return in_myo.mean() >= 0.10
234
+
235
+ # Step 4 — hole ratio test
236
+ hole_ratio = I_nuc / I_ring
237
+ return hole_ratio < hole_ratio_thr
238
+
239
+
240
  # ─────────────────────────────────────────────────────────────────────────────
241
  # Biological metrics (counting + fusion + surface area)
242
  # ─────────────────────────────────────────────────────────────────────────────
243
 
244
  def compute_bio_metrics(nuc_mask, myo_mask,
245
+ myc_channel_full=None,
246
+ min_overlap_frac=0.10,
247
  nuc_ws_min_distance=3,
248
  nuc_ws_min_area=6,
249
+ px_um=1.0,
250
+ ring_width=6,
251
+ hole_ratio_thr=0.85) -> dict:
252
+ """
253
+ Compute all biological metrics.
254
+
255
+ If myc_channel_full (the raw MyHC grayscale image at original resolution)
256
+ is supplied, uses the cytoplasm-hole method (MyoFuse, Lair et al. 2025)
257
+ to classify each nucleus — eliminates Z-stack overlap false positives and
258
+ gives an accurate, non-overestimated fusion index.
259
+
260
+ If myc_channel_full is None, falls back to the original pixel-overlap
261
+ method for backward compatibility.
262
+ """
263
  nuc_lab = label_nuclei_watershed(nuc_mask,
264
  min_distance=nuc_ws_min_distance,
265
  min_nuc_area=nuc_ws_min_area)
266
  myo_lab = label_cc(myo_mask)
267
  total = int(nuc_lab.max())
268
 
269
+ # Resize masks/channel to the SAME space for comparison
270
+ # nuc_lab and myo_mask are at model resolution (e.g. 512×512).
271
+ # myc_channel_full is at original image resolution.
272
+ # We resize everything to original resolution for the cytoplasm-hole test.
273
+ if myc_channel_full is not None:
274
+ H_full, W_full = myc_channel_full.shape
275
+ # Resize label maps up to original resolution
276
+ nuc_lab_full = np.array(
277
+ Image.fromarray(nuc_lab.astype(np.int32))
278
+ .resize((W_full, H_full), Image.NEAREST)
279
+ )
280
+ myo_mask_full = np.array(
281
+ Image.fromarray((myo_mask * 255).astype(np.uint8))
282
+ .resize((W_full, H_full), Image.NEAREST)
283
+ ) > 0
284
+ # Normalise MyHC channel to 0-1 float
285
+ myc_f = myc_channel_full.astype(np.float32)
286
+ if myc_f.max() > 1.0:
287
+ myc_f = myc_f / 255.0
288
+ else:
289
+ nuc_lab_full = nuc_lab
290
+ myo_mask_full = myo_mask.astype(bool)
291
+ myc_f = None
292
+
293
  pos, nm = 0, {}
294
+ for prop in measure.regionprops(nuc_lab_full):
295
+ coords = prop.coords # (N,2) in full-res space
296
+
297
+ if myc_f is not None:
298
+ # ── Cytoplasm-hole method (accurate, MyoFuse 2025) ────────────────
299
+ is_fused = classify_nucleus_in_myotube(
300
+ coords, myc_f, myo_mask_full,
301
+ ring_width=ring_width,
302
+ hole_ratio_thr=hole_ratio_thr,
303
+ )
304
+ else:
305
+ # ── Legacy pixel-overlap fallback ─────────────────────────────────
306
+ ids = myo_mask_full.astype(np.uint8)[coords[:, 0], coords[:, 1]]
307
+ frac = ids.sum() / max(len(coords), 1)
308
+ is_fused = frac >= min_overlap_frac
309
+
310
+ if is_fused:
311
+ # Find which myotube this nucleus belongs to (use model-res myo_lab)
312
+ # Scale coords back to model resolution
313
+ if myc_f is not None:
314
+ r_m = np.clip((coords[:, 0] * nuc_lab.shape[0] / H_full).astype(int),
315
+ 0, nuc_lab.shape[0] - 1)
316
+ c_m = np.clip((coords[:, 1] * nuc_lab.shape[1] / W_full).astype(int),
317
+ 0, nuc_lab.shape[1] - 1)
318
+ ids_mt = myo_lab[r_m, c_m]
319
+ else:
320
+ ids_mt = myo_lab[coords[:, 0], coords[:, 1]]
321
+
322
+ ids_mt = ids_mt[ids_mt > 0]
323
+ if ids_mt.size > 0:
324
+ unique, counts = np.unique(ids_mt, return_counts=True)
325
+ mt = int(unique[np.argmax(counts)])
326
+ nm.setdefault(mt, []).append(prop.label)
327
  pos += 1
 
328
 
329
  per = [len(v) for v in nm.values()]
330
  fused = sum(n for n in per if n >= 2)
 
345
  "total_area_um2" : sa["total_area_um2"],
346
  "mean_area_um2" : sa["mean_area_um2"],
347
  "max_area_um2" : sa["max_area_um2"],
348
+ "_per_myotube_areas" : sa["per_myotube_areas"],
349
  }
350
 
351
 
 
371
  def make_coloured_overlay(rgb_u8: np.ndarray,
372
  nuc_lab: np.ndarray,
373
  myo_lab: np.ndarray,
374
+ alpha: float = 0.45,
375
+ nuc_color: tuple = None,
376
+ myo_color: tuple = None) -> np.ndarray:
377
  """
378
  Colour the mask regions only — NO text baked in.
379
  Returns an RGB uint8 array at original image resolution.
380
+
381
+ nuc_color / myo_color: RGB tuple e.g. (0, 255, 255).
382
+ If None, uses per-instance colourmaps (cool / autumn).
383
+ If provided, uses a flat solid colour for all instances of that type —
384
+ this is what the sidebar colour pickers control.
385
  """
386
  orig_h, orig_w = rgb_u8.shape[:2]
387
  nuc_cmap = plt.cm.get_cmap("cool")
 
399
 
400
  base = rgb_u8.astype(np.float32).copy()
401
  if n_myo > 0:
 
 
402
  mask = myo_disp > 0
403
+ if myo_color is not None:
404
+ colour_layer = np.array(myo_color, dtype=np.float32)
405
+ base[mask] = (1 - alpha) * base[mask] + alpha * colour_layer
406
+ else:
407
+ myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
408
+ myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
409
+ base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
410
+
411
  if n_nuc > 0:
 
 
412
  mask = nuc_disp > 0
413
+ if nuc_color is not None:
414
+ colour_layer = np.array(nuc_color, dtype=np.float32)
415
+ base[mask] = (1 - alpha) * base[mask] + alpha * colour_layer
416
+ else:
417
+ nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
418
+ nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
419
+ base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
420
 
421
  return np.clip(base, 0, 255).astype(np.uint8)
422
 
 
969
  thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.50, 0.01)
970
  thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.50, 0.01)
971
 
972
+ st.header("Fusion Index method")
973
+ fi_method = st.radio(
974
+ "FI classification method",
975
+ ["Cytoplasm-hole (accurate, Lair 2025)", "Pixel-overlap (legacy)"],
976
+ index=0,
977
+ help=(
978
+ "Cytoplasm-hole: checks for a MyHC signal dip beneath each nucleus — "
979
+ "eliminates false positives from nuclei sitting above/below myotubes in Z. "
980
+ "Pixel-overlap: legacy method that overestimates FI (Lair et al. 2025)."
981
+ )
982
+ )
983
+ use_hole_method = fi_method.startswith("Cytoplasm")
984
+ hole_ratio_thr = st.slider(
985
+ "Hole ratio threshold", 0.50, 0.99, 0.85, 0.01,
986
+ help=(
987
+ "A nucleus is counted as fused if its MyHC signal is less than "
988
+ "this fraction of the surrounding cytoplasm ring signal. "
989
+ "Lower = stricter (fewer nuclei counted as fused). "
990
+ "0.85 is the value validated by Lair et al. 2025."
991
+ ),
992
+ disabled=not use_hole_method,
993
+ )
994
+ ring_width_px = st.number_input(
995
+ "Cytoplasm ring width (px)", 2, 20, 6, 1,
996
+ help="Width of the ring around each nucleus used to measure local MyHC intensity.",
997
+ disabled=not use_hole_method,
998
+ )
999
+
1000
  st.header("Postprocessing")
1001
  min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
1002
  min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
 
1075
  dtype=np.uint8
1076
  )
1077
 
1078
+ ch1 = get_channel(rgb_u8, src1) # MyHC channel
1079
+ ch2 = get_channel(rgb_u8, src2) # DAPI / nuclei channel
1080
  if inv1: ch1 = 255 - ch1
1081
  if inv2: ch2 = 255 - ch2
1082
 
1083
+ # Keep the full-resolution MyHC channel for the cytoplasm-hole
1084
+ # FI classifier — must be at original image resolution
1085
+ myc_full = ch1.copy() # uint8, original resolution
1086
+
1087
  H = W = int(image_size)
1088
  x1 = resize_u8_to_float01(ch1, W, H, Image.BILINEAR)
1089
  x2 = resize_u8_to_float01(ch2, W, H, Image.BILINEAR)
 
1133
 
1134
  bio = compute_bio_metrics(
1135
  nuc_pp, myo_pp,
1136
+ myc_channel_full=myc_full if use_hole_method else None,
1137
  nuc_ws_min_distance=int(nuc_ws_min_dist),
1138
  nuc_ws_min_area=int(nuc_ws_min_area),
1139
  px_um=float(px_um),
1140
+ ring_width=int(ring_width_px),
1141
+ hole_ratio_thr=float(hole_ratio_thr),
1142
  )
1143
+ bio["fi_method"] = "cytoplasm-hole" if use_hole_method else "pixel-overlap"
1144
  per_areas = bio.pop("_per_myotube_areas", [])
1145
  bio["image"] = name
1146
  results.append(bio)
1147
  all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
1148
 
 
 
 
1149
  artifacts[name] = {
1150
+ # raw pixel data overlays built at display time from these
1151
+ "rgb_u8" : rgb_u8,
1152
+ "nuc_lab" : nuc_lab,
1153
+ "myo_lab" : myo_lab,
1154
+ # static mask PNGs
1155
  "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
1156
  "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
1157
+ "nuc_raw_bytes" : png_bytes((nuc_raw*255).astype(np.uint8)),
1158
+ "myo_raw_bytes" : png_bytes((myo_raw*255).astype(np.uint8)),
 
 
1159
  # label positions for SVG viewer
1160
  "label_positions": label_positions,
1161
  # image dimensions
 
1163
  "img_h" : orig_h_img,
1164
  }
1165
 
1166
+ # ZIP built with current colour settings at run time
1167
  zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
1168
  zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
1169
  zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
1170
  zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
1171
  zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
1172
  zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
1173
+ zf.writestr(f"{name}/nuclei_raw.png", artifacts[name]["nuc_raw_bytes"])
1174
+ zf.writestr(f"{name}/myotube_raw.png", artifacts[name]["myo_raw_bytes"])
1175
 
1176
  prog.progress((i + 1) / len(uploads))
1177
 
 
1202
  display_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
1203
  st.dataframe(st.session_state.df[display_cols], use_container_width=True, height=320)
1204
 
1205
+ c1, c2, c3 = st.columns(3)
1206
  with c1:
1207
  st.download_button("⬇️ Download metrics.csv",
1208
  st.session_state.df[display_cols].to_csv(index=False).encode(),
 
1211
  st.download_button("⬇️ Download results.zip",
1212
  st.session_state.zip_bytes,
1213
  file_name="results.zip", mime="application/zip")
1214
+ with c3:
1215
+ # Rebuild ZIP with CURRENT colour / alpha settings — no model rerun needed
1216
+ if st.button("🎨 Rebuild ZIP with current colours", help=(
1217
+ "Regenerates the overlay images in the ZIP using the current "
1218
+ "colour picker and alpha values from the sidebar."
1219
+ )):
1220
+ import base64 as _b64_zip
1221
+ new_zip_buf = io.BytesIO()
1222
+ with zipfile.ZipFile(new_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1223
+ for img_name, art in st.session_state.artifacts.items():
1224
+ _r = art["rgb_u8"]
1225
+ _nl = art["nuc_lab"]
1226
+ _ml = art["myo_lab"]
1227
+ _zn = np.zeros_like(_nl)
1228
+ _zm = np.zeros_like(_ml)
1229
+ ov_comb = make_coloured_overlay(_r, _nl, _ml,
1230
+ alpha=float(alpha),
1231
+ nuc_color=nuc_rgb, myo_color=myo_rgb)
1232
+ ov_nuc = make_coloured_overlay(_r, _nl, _zm,
1233
+ alpha=float(alpha),
1234
+ nuc_color=nuc_rgb, myo_color=None)
1235
+ ov_myo = make_coloured_overlay(_r, _zn, _ml,
1236
+ alpha=float(alpha),
1237
+ nuc_color=None, myo_color=myo_rgb)
1238
+ simple = make_simple_overlay(_r,
1239
+ (_nl > 0).astype(np.uint8),
1240
+ (_ml > 0).astype(np.uint8),
1241
+ nuc_rgb, myo_rgb, float(alpha))
1242
+ zf.writestr(f"{img_name}/overlay_combined.png", png_bytes(simple))
1243
+ zf.writestr(f"{img_name}/overlay_instance.png", png_bytes(ov_comb))
1244
+ zf.writestr(f"{img_name}/overlay_nuclei.png", png_bytes(ov_nuc))
1245
+ zf.writestr(f"{img_name}/overlay_myotubes.png", png_bytes(ov_myo))
1246
+ zf.writestr(f"{img_name}/nuclei_pp.png", art["nuc_pp"])
1247
+ zf.writestr(f"{img_name}/myotube_pp.png", art["myo_pp"])
1248
+ zf.writestr(f"{img_name}/nuclei_raw.png", art["nuc_raw_bytes"])
1249
+ zf.writestr(f"{img_name}/myotube_raw.png", art["myo_raw_bytes"])
1250
+ df_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
1251
+ zf.writestr("metrics.csv", st.session_state.df[df_cols].to_csv(index=False).encode())
1252
+ st.session_state.zip_bytes = new_zip_buf.getvalue()
1253
+ st.success("ZIP rebuilt with current colours. Click Download results.zip above to save.")
1254
 
1255
  st.divider()
1256
 
 
1278
  iw = art["img_w"]
1279
  ih = art["img_h"]
1280
 
1281
+ # Build coloured overlays RIGHT NOW using the current sidebar colour / alpha.
1282
+ # This means changing colour picker or alpha slider instantly updates the
1283
+ # viewer — no rerun needed for display changes.
1284
+ import base64 as _b64_disp
1285
+ def _b64png_disp(arr):
1286
+ return _b64_disp.b64encode(png_bytes(arr)).decode()
1287
+
1288
+ _rgb = art["rgb_u8"]
1289
+ _nl = art["nuc_lab"]
1290
+ _ml = art["myo_lab"]
1291
+ _zero_nuc = np.zeros_like(_nl)
1292
+ _zero_myo = np.zeros_like(_ml)
1293
+
1294
+ inst_b64 = _b64png_disp(make_coloured_overlay(_rgb, _nl, _ml,
1295
+ alpha=float(alpha),
1296
+ nuc_color=nuc_rgb, myo_color=myo_rgb))
1297
+ nuc_only_b64 = _b64png_disp(make_coloured_overlay(_rgb, _nl, _zero_myo,
1298
+ alpha=float(alpha),
1299
+ nuc_color=nuc_rgb, myo_color=None))
1300
+ myo_only_b64 = _b64png_disp(make_coloured_overlay(_rgb, _zero_nuc, _ml,
1301
+ alpha=float(alpha),
1302
+ nuc_color=None, myo_color=myo_rgb))
1303
+
1304
  with tabs[0]:
1305
  html_combined = make_svg_viewer(
1306
+ inst_b64, iw, ih, lpos,
1307
  show_nuclei=True, show_myotubes=True,
1308
  )
1309
  st.components.v1.html(html_combined, height=680, scrolling=False)
 
1311
  with tabs[1]:
1312
  nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
1313
  html_nuc = make_svg_viewer(
1314
+ nuc_only_b64, iw, ih, nuc_only_lpos,
1315
  show_nuclei=True, show_myotubes=False,
1316
  )
1317
  st.components.v1.html(html_nuc, height=680, scrolling=False)
 
1319
  with tabs[2]:
1320
  myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
1321
  html_myo = make_svg_viewer(
1322
+ myo_only_b64, iw, ih, myo_only_lpos,
1323
  show_nuclei=False, show_myotubes=True,
1324
  )
1325
  st.components.v1.html(html_myo, height=680, scrolling=False)
1326
 
1327
  with tabs[3]:
1328
+ st.image(art["rgb_u8"], use_container_width=True)
1329
  with tabs[4]:
1330
  st.image(art["nuc_pp"], use_container_width=True)
1331
  with tabs[5]: