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

Update Streamlit app to v8 and self_train to v2

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +162 -56
src/streamlit_app.py CHANGED
@@ -4,20 +4,25 @@ MyoSight β€” Myotube & Nuclei Analyser
4
  ========================================
5
  Drop-in replacement for streamlit_app.py on Hugging Face Spaces.
6
 
7
- New features vs the original Myotube Analyzer V2:
8
  ✦ Animated count-up metrics (9 counters)
9
  ✦ Instance overlay β€” nucleus IDs (1,2,3…) + myotube IDs (M1,M2…)
 
10
  ✦ Watershed nuclei splitting for accurate counts
11
  ✦ Myotube surface area (total, mean, max Β΅mΒ²) + per-tube bar chart
12
  ✦ Active learning β€” upload corrected masks β†’ saved to corrections/
13
  ✦ Low-confidence auto-flagging β†’ image queued for retraining
14
  ✦ Retraining queue status panel
15
  ✦ All original sidebar controls preserved
16
- ✦ Myotube merging fixes (v7):
17
- β€” myo_erode_radius: erosion before labelling breaks thin bridges (Fix 1)
18
- β€” myo_max_area_px + myo_split_min_seeds: nucleus-seeded watershed splits
19
- oversized merged regions (Fix 2+3)
20
- β€” Validated against 57-well manual count dataset
 
 
 
 
21
  """
22
 
23
  import io
@@ -46,7 +51,7 @@ from huggingface_hub import hf_hub_download
46
  import scipy.ndimage as ndi
47
  from skimage.morphology import remove_small_objects, disk, closing, opening, binary_dilation, binary_erosion
48
  from skimage import measure
49
- from skimage.segmentation import watershed
50
  from skimage.feature import peak_local_max
51
 
52
 
@@ -103,39 +108,68 @@ def hex_to_rgb(h: str):
103
 
104
  def postprocess_masks(nuc_mask, myo_mask,
105
  min_nuc_area=20, min_myo_area=500,
106
- nuc_close_radius=2, myo_close_radius=3,
107
- myo_erode_radius=0):
 
 
108
  """
109
  Clean up raw predicted masks.
110
 
111
- Nuclei: optional closing to fill gaps, then remove small objects.
112
- Myotubes: closing + opening to smooth edges, optional erosion to break
113
- thin bridges between touching myotubes, then remove small objects.
114
-
115
- myo_erode_radius β€” disk radius for morphological erosion applied AFTER
116
- closing/opening and BEFORE connected-components labelling.
117
- Separates adjacent/touching myotubes that would otherwise
118
- be merged into a single connected region.
119
- Start at 1–2 px; increase to 3–4 px for dense networks.
120
- Set to 0 to disable (default, preserves original behaviour).
 
 
 
 
 
 
 
121
  """
122
- # Nuclei
123
  nuc_bin = nuc_mask.astype(bool)
124
  if int(nuc_close_radius) > 0:
125
  nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
126
  nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
127
 
128
- # Myotubes β€” close β†’ open β†’ optional erode
129
- selem = disk(int(myo_close_radius))
130
- myo_bin = closing(myo_mask.astype(bool), selem)
131
- myo_bin = opening(myo_bin, selem)
132
  if int(myo_erode_radius) > 0:
133
- myo_bin = binary_erosion(myo_bin, disk(int(myo_erode_radius)))
134
- myo_clean = remove_small_objects(myo_bin, min_size=int(min_myo_area)).astype(np.uint8)
 
 
 
 
 
135
 
136
  return nuc_clean, myo_clean
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def label_cc(mask: np.ndarray) -> np.ndarray:
140
  lab, _ = ndi.label(mask.astype(np.uint8))
141
  return lab
@@ -521,6 +555,45 @@ def make_coloured_overlay(rgb_u8: np.ndarray,
521
  return np.clip(base, 0, 255).astype(np.uint8)
522
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  def collect_label_positions(nuc_lab: np.ndarray,
525
  myo_lab: np.ndarray,
526
  img_w: int, img_h: int) -> dict:
@@ -1066,8 +1139,8 @@ with st.sidebar:
1066
  options=[256, 384, 512, 640, 768, 1024], value=512)
1067
 
1068
  st.header("Thresholds")
1069
- thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.50, 0.01)
1070
- thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.50, 0.01)
1071
 
1072
  st.header("Fusion Index method")
1073
  fi_method = st.radio(
@@ -1101,41 +1174,47 @@ with st.sidebar:
1101
  min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
1102
  min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
1103
  nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
1104
- myo_close_radius = st.number_input("Myotube close radius", 0, 50, 3, 1)
 
 
1105
 
1106
- st.header("Myotube splitting (fix merging)")
1107
  st.caption(
1108
- "Validation showed MyoSight merges touching/branching myotubes into "
1109
- "single objects, under-counting by ~50–90% in dense cultures. "
1110
- "These three controls correct that."
1111
  )
1112
  myo_erode_radius = st.number_input(
1113
  "Myotube erode radius (px)", 0, 15, 2, 1,
1114
  help=(
1115
- "Erodes the myotube mask before labelling to break thin bridges "
1116
- "between adjacent touching myotubes. Start at 2 px; increase to "
1117
- "3-4 px for very dense/mature cultures. Set 0 to disable. "
1118
- "This is Fix 1 β€” the fastest and most impactful change."
 
 
 
 
 
 
 
 
 
1119
  )
1120
  )
1121
  myo_max_area_px = st.number_input(
1122
- "Max myotube area before split (pxΒ²)", 0, 500000, 8000, 500,
1123
  help=(
1124
- "Any connected myotube region larger than this is treated as a merged "
1125
- "network and split using nucleus-seeded watershed (Fix 2+3). "
1126
- "Rule of thumb: set to ~(expected nuc/myo) x (avg nucleus area px2) x 3. "
1127
- "With ~5 nuc/myo and ~300 px nuclei, try 8000-15000 px2. "
1128
- "Set to 0 to disable automatic splitting."
1129
  )
1130
  )
1131
  myo_split_min_seeds = st.number_input(
1132
- "Min nuclei seeds to split", 2, 20, 3, 1,
1133
  help=(
1134
- "A merged region needs at least this many nucleus centroids inside "
1135
- "it before watershed splitting is attempted. Prevents over-splitting "
1136
- "of legitimately large single myotubes. "
1137
- "Increase if single large myotubes are being incorrectly split. "
1138
- "Decrease if merged regions with few nuclei are being missed."
1139
  )
1140
  )
1141
 
@@ -1244,8 +1323,9 @@ if run:
1244
  min_nuc_area=int(min_nuc_area),
1245
  min_myo_area=int(min_myo_area),
1246
  nuc_close_radius=int(nuc_close_radius),
1247
- myo_close_radius=int(myo_close_radius),
1248
  myo_erode_radius=int(myo_erode_radius),
 
1249
  )
1250
 
1251
  # Flat overlay for ZIP (no labels β€” just colour regions)
@@ -1297,6 +1377,9 @@ if run:
1297
  "rgb_u8" : rgb_u8,
1298
  "nuc_lab" : nuc_lab,
1299
  "myo_lab" : myo_lab,
 
 
 
1300
  # static mask PNGs
1301
  "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
1302
  "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
@@ -1310,10 +1393,14 @@ if run:
1310
  }
1311
 
1312
  # ZIP built with current colour settings at run time
 
 
 
1313
  zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
1314
  zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
1315
  zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
1316
  zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
 
1317
  zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
1318
  zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
1319
  zf.writestr(f"{name}/nuclei_raw.png", artifacts[name]["nuc_raw_bytes"])
@@ -1385,10 +1472,14 @@ with c3:
1385
  (_nl > 0).astype(np.uint8),
1386
  (_ml > 0).astype(np.uint8),
1387
  nuc_rgb, myo_rgb, float(alpha))
 
 
 
1388
  zf.writestr(f"{img_name}/overlay_combined.png", png_bytes(simple))
1389
  zf.writestr(f"{img_name}/overlay_instance.png", png_bytes(ov_comb))
1390
  zf.writestr(f"{img_name}/overlay_nuclei.png", png_bytes(ov_nuc))
1391
  zf.writestr(f"{img_name}/overlay_myotubes.png", png_bytes(ov_myo))
 
1392
  zf.writestr(f"{img_name}/nuclei_pp.png", art["nuc_pp"])
1393
  zf.writestr(f"{img_name}/myotube_pp.png", art["myo_pp"])
1394
  zf.writestr(f"{img_name}/nuclei_raw.png", art["nuc_raw_bytes"])
@@ -1412,6 +1503,7 @@ col_img, col_metrics = st.columns([3, 2], gap="large")
1412
  with col_img:
1413
  tabs = st.tabs([
1414
  "πŸ”΅ Combined",
 
1415
  "🟣 Nuclei only",
1416
  "🟠 Myotubes only",
1417
  "πŸ“· Original",
@@ -1455,6 +1547,21 @@ with col_img:
1455
  st.components.v1.html(html_combined, height=680, scrolling=False)
1456
 
1457
  with tabs[1]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1458
  nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
1459
  html_nuc = make_svg_viewer(
1460
  nuc_only_b64, iw, ih, nuc_only_lpos,
@@ -1462,7 +1569,7 @@ with col_img:
1462
  )
1463
  st.components.v1.html(html_nuc, height=680, scrolling=False)
1464
 
1465
- with tabs[2]:
1466
  myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
1467
  html_myo = make_svg_viewer(
1468
  myo_only_b64, iw, ih, myo_only_lpos,
@@ -1470,11 +1577,11 @@ with col_img:
1470
  )
1471
  st.components.v1.html(html_myo, height=680, scrolling=False)
1472
 
1473
- with tabs[3]:
1474
- st.image(art["rgb_u8"], use_container_width=True)
1475
  with tabs[4]:
1476
- st.image(art["nuc_pp"], use_container_width=True)
1477
  with tabs[5]:
 
 
1478
  st.image(art["myo_pp"], use_container_width=True)
1479
 
1480
  with col_metrics:
@@ -1550,8 +1657,7 @@ if enable_al:
1550
  if corr_nuc is None or corr_myo is None:
1551
  st.error("Please upload BOTH a nuclei mask and a myotube mask.")
1552
  else:
1553
- orig_bytes = st.session_state.artifacts[al_pick]["original"]
1554
- orig_rgb = np.array(Image.open(io.BytesIO(orig_bytes)).convert("RGB"))
1555
  nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
1556
  myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
1557
  add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
 
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
 
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
 
 
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
 
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:
 
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(
 
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
 
 
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)
 
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)),
 
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"])
 
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"])
 
1503
  with col_img:
1504
  tabs = st.tabs([
1505
  "πŸ”΅ Combined",
1506
+ "πŸ“ Outlines",
1507
  "🟣 Nuclei only",
1508
  "🟠 Myotubes only",
1509
  "πŸ“· Original",
 
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,
 
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,
 
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:
 
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,