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

Fix file path and replace streamlit_app.py with v3

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +151 -47
src/streamlit_app.py CHANGED
@@ -97,12 +97,19 @@ def hex_to_rgb(h: str):
97
 
98
  def postprocess_masks(nuc_mask, myo_mask,
99
  min_nuc_area=20, min_myo_area=500,
100
- myo_close_radius=3):
101
- """Original closing-based postprocess β€” unchanged from V2."""
102
- nuc_clean = remove_small_objects(
103
- nuc_mask.astype(bool), min_size=int(min_nuc_area)
104
- ).astype(np.uint8)
 
 
 
 
 
 
105
 
 
106
  selem = disk(int(myo_close_radius))
107
  myo_bin = closing(myo_mask.astype(bool), selem)
108
  myo_bin = opening(myo_bin, selem)
@@ -231,73 +238,127 @@ def make_instance_overlay(rgb_u8: np.ndarray,
231
  label_nuclei: bool = True,
232
  label_myotubes: bool = True) -> np.ndarray:
233
  """
234
- Per-instance coloured overlay rendered with matplotlib.
235
- Nuclei β†’ cool colourmap with white numeric IDs.
236
- Myotubes β†’ autumn colourmap with M1, M2… IDs.
237
- Returns RGB uint8 array at original image resolution.
 
 
 
 
 
238
  """
239
  orig_h, orig_w = rgb_u8.shape[:2]
240
  nuc_cmap = plt.cm.get_cmap("cool")
241
  myo_cmap = plt.cm.get_cmap("autumn")
242
 
 
243
  def _resize_lab(lab, h, w):
244
- return np.array(Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST))
 
 
245
 
246
  nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
247
  myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
248
- base = rgb_u8.astype(np.float32).copy()
249
- n_myo = int(myo_disp.max())
250
  n_nuc = int(nuc_disp.max())
 
251
 
 
 
252
  if n_myo > 0:
253
  myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
254
  myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
255
  mask = myo_disp > 0
256
  base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
257
-
258
  if n_nuc > 0:
259
  nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
260
  nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
261
  mask = nuc_disp > 0
262
  base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
263
-
264
  overlay = np.clip(base, 0, 255).astype(np.uint8)
265
 
266
- dpi = 100
267
- fig, ax = plt.subplots(figsize=(orig_w / dpi, orig_h / dpi), dpi=dpi)
 
 
 
 
 
 
 
268
  ax.imshow(overlay)
 
 
269
  ax.axis("off")
270
 
271
- scale_x = orig_w / nuc_lab.shape[1]
272
- scale_y = orig_h / nuc_lab.shape[0]
273
- font_nuc = max(3, min(6, orig_w // 200))
274
- font_myo = max(4, min(8, orig_w // 150))
 
275
 
 
276
  if label_nuclei:
277
  for prop in measure.regionprops(nuc_lab):
278
  r, c = prop.centroid
279
- ax.text(c * scale_x, r * scale_y, str(prop.label),
280
- fontsize=font_nuc, color="white", ha="center", va="center",
281
- fontweight="bold",
282
- bbox=dict(boxstyle="round,pad=0.1", fc="steelblue", alpha=0.6, lw=0))
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
 
284
  if label_myotubes:
285
  for prop in measure.regionprops(myo_lab):
286
  r, c = prop.centroid
287
- ax.text(c * scale_x, r * scale_y, f"M{prop.label}",
288
- fontsize=font_myo, color="white", ha="center", va="center",
289
- fontweight="bold",
290
- bbox=dict(boxstyle="round,pad=0.1", fc="darkred", alpha=0.6, lw=0))
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
 
292
  patches = [
293
  mpatches.Patch(color=nuc_cmap(0.7), label=f"Nuclei (n={n_nuc})"),
294
  mpatches.Patch(color=myo_cmap(0.7), label=f"Myotubes (n={n_myo})"),
295
  ]
296
- ax.legend(handles=patches, loc="upper right", fontsize=max(5, orig_w // 200),
297
- framealpha=0.75, facecolor="#111", labelcolor="white")
 
 
 
 
 
 
 
298
 
299
  fig.tight_layout(pad=0)
300
  buf = io.BytesIO()
 
301
  fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0, dpi=dpi)
302
  plt.close(fig)
303
  buf.seek(0)
@@ -466,6 +527,7 @@ with st.sidebar:
466
  st.header("Postprocessing")
467
  min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
468
  min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
 
469
  myo_close_radius = st.number_input("Myotube close radius", 0, 50, 3, 1)
470
 
471
  st.header("Watershed (nuclei splitting)")
@@ -568,6 +630,7 @@ if run:
568
  nuc_raw, myo_raw,
569
  min_nuc_area=int(min_nuc_area),
570
  min_myo_area=int(min_myo_area),
 
571
  myo_close_radius=int(myo_close_radius),
572
  )
573
 
@@ -576,16 +639,33 @@ if run:
576
  rgb_u8, nuc_pp, myo_pp, nuc_rgb, myo_rgb, float(alpha)
577
  )
578
 
579
- # Instance overlay for display
580
  nuc_lab = label_nuclei_watershed(nuc_pp,
581
  min_distance=int(nuc_ws_min_dist),
582
  min_nuc_area=int(nuc_ws_min_area))
583
  myo_lab = label_cc(myo_pp)
 
 
584
  inst_ov = make_instance_overlay(rgb_u8, nuc_lab, myo_lab,
585
  alpha=float(alpha),
586
  label_nuclei=label_nuc,
587
  label_myotubes=label_myo)
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  bio = compute_bio_metrics(
590
  nuc_pp, myo_pp,
591
  nuc_ws_min_distance=int(nuc_ws_min_dist),
@@ -598,19 +678,23 @@ if run:
598
  all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
599
 
600
  artifacts[name] = {
601
- "original" : png_bytes(rgb_u8),
602
- "overlay" : png_bytes(inst_ov),
603
- "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
604
- "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
 
 
605
  }
606
 
607
  # ZIP contents
608
- zf.writestr(f"{name}/overlay.png", png_bytes(simple_ov))
609
- zf.writestr(f"{name}/instance_overlay.png", png_bytes(inst_ov))
610
- zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
611
- zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
612
- zf.writestr(f"{name}/nuclei_raw.png", png_bytes((nuc_raw*255).astype(np.uint8)))
613
- zf.writestr(f"{name}/myotube_raw.png", png_bytes((myo_raw*255).astype(np.uint8)))
 
 
614
 
615
  prog.progress((i + 1) / len(uploads))
616
 
@@ -663,13 +747,33 @@ pick = st.selectbox("Select image", names)
663
  col_img, col_metrics = st.columns([3, 2], gap="large")
664
 
665
  with col_img:
666
- tabs = st.tabs(["Instance overlay", "Original", "Nuclei mask", "Myotube mask"])
667
- art = st.session_state.artifacts[pick]
 
 
 
 
 
 
 
 
668
  FIXED_W = 700
669
- with tabs[0]: st.image(art["overlay"], width=FIXED_W)
670
- with tabs[1]: st.image(art["original"], width=FIXED_W)
671
- with tabs[2]: st.image(art["nuc_pp"], width=FIXED_W)
672
- with tabs[3]: st.image(art["myo_pp"], width=FIXED_W)
 
 
 
 
 
 
 
 
 
 
 
 
673
 
674
  with col_metrics:
675
  st.markdown("#### πŸ“Š Live metrics")
 
97
 
98
  def postprocess_masks(nuc_mask, myo_mask,
99
  min_nuc_area=20, min_myo_area=500,
100
+ nuc_close_radius=2, myo_close_radius=3):
101
+ """
102
+ Clean up raw predicted masks.
103
+ Nuclei: optional closing to fill gaps, then remove small objects.
104
+ Myotubes: closing + opening to smooth edges, then remove small objects.
105
+ """
106
+ # Nuclei
107
+ nuc_bin = nuc_mask.astype(bool)
108
+ if int(nuc_close_radius) > 0:
109
+ nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
110
+ nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
111
 
112
+ # Myotubes
113
  selem = disk(int(myo_close_radius))
114
  myo_bin = closing(myo_mask.astype(bool), selem)
115
  myo_bin = opening(myo_bin, selem)
 
238
  label_nuclei: bool = True,
239
  label_myotubes: bool = True) -> np.ndarray:
240
  """
241
+ Per-instance coloured overlay rendered at high DPI so labels stay sharp
242
+ when the image is zoomed in.
243
+
244
+ Nuclei β†’ cool colourmap, white numeric IDs on solid dark-blue backing.
245
+ Myotubes β†’ autumn colourmap, white M1/M2… IDs on solid dark-red backing.
246
+
247
+ Font sizes are fixed in data-space pixels so they look the same regardless
248
+ of image resolution. Myotube labels are always 3Γ— bigger than nucleus
249
+ labels so the two tiers are visually distinct at any zoom level.
250
  """
251
  orig_h, orig_w = rgb_u8.shape[:2]
252
  nuc_cmap = plt.cm.get_cmap("cool")
253
  myo_cmap = plt.cm.get_cmap("autumn")
254
 
255
+ # ── resize label maps to original image resolution ───────────────────────
256
  def _resize_lab(lab, h, w):
257
+ return np.array(
258
+ Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
259
+ )
260
 
261
  nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
262
  myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
 
 
263
  n_nuc = int(nuc_disp.max())
264
+ n_myo = int(myo_disp.max())
265
 
266
+ # ── colour the mask regions ───────────────────────────────────────────────
267
+ base = rgb_u8.astype(np.float32).copy()
268
  if n_myo > 0:
269
  myo_norm = (myo_disp / max(n_myo, 1)).astype(np.float32)
270
  myo_rgba = (myo_cmap(myo_norm)[:, :, :3] * 255).astype(np.float32)
271
  mask = myo_disp > 0
272
  base[mask] = (1 - alpha) * base[mask] + alpha * myo_rgba[mask]
 
273
  if n_nuc > 0:
274
  nuc_norm = (nuc_disp / max(n_nuc, 1)).astype(np.float32)
275
  nuc_rgba = (nuc_cmap(nuc_norm)[:, :, :3] * 255).astype(np.float32)
276
  mask = nuc_disp > 0
277
  base[mask] = (1 - alpha) * base[mask] + alpha * nuc_rgba[mask]
 
278
  overlay = np.clip(base, 0, 255).astype(np.uint8)
279
 
280
+ # ── render at high DPI so the PNG is sharp when zoomed ───────────────────
281
+ # We render the figure at the ORIGINAL pixel size Γ— a scale factor,
282
+ # then downsample back β€” this keeps labels crisp at zoom.
283
+ RENDER_SCALE = 2 # render at 2Γ— then downsample β†’ no blur
284
+ dpi = 150
285
+ fig_w = orig_w * RENDER_SCALE / dpi
286
+ fig_h = orig_h * RENDER_SCALE / dpi
287
+
288
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi)
289
  ax.imshow(overlay)
290
+ ax.set_xlim(0, orig_w)
291
+ ax.set_ylim(orig_h, 0)
292
  ax.axis("off")
293
 
294
+ # ── font sizes: fixed in figure points, independent of image size ────────
295
+ # At RENDER_SCALE=2, dpi=150: 1 data pixel β‰ˆ 1/75 inch.
296
+ # We want nucleus labels ~8–10 pt and myotube labels ~18–22 pt.
297
+ font_nuc = 9 # pt β€” clearly readable when zoomed, not overwhelming at full view
298
+ font_myo = 20 # pt β€” dominant, impossible to miss
299
 
300
+ # ── nucleus labels ────────────────────────────────────────────────────────
301
  if label_nuclei:
302
  for prop in measure.regionprops(nuc_lab):
303
  r, c = prop.centroid
304
+ # scale centroid from prediction-space to display-space
305
+ cx = c * (orig_w / nuc_lab.shape[1])
306
+ cy = r * (orig_h / nuc_lab.shape[0])
307
+ ax.text(
308
+ cx, cy, str(prop.label),
309
+ fontsize=font_nuc,
310
+ color="white",
311
+ ha="center", va="center",
312
+ fontweight="bold",
313
+ bbox=dict(
314
+ boxstyle="round,pad=0.25",
315
+ fc="#003366", # solid dark-blue β€” fully opaque
316
+ ec="none",
317
+ alpha=0.92,
318
+ ),
319
+ zorder=2,
320
+ )
321
 
322
+ # ── myotube labels ────────────────────────────────────────────────────────
323
  if label_myotubes:
324
  for prop in measure.regionprops(myo_lab):
325
  r, c = prop.centroid
326
+ cx = c * (orig_w / myo_lab.shape[1])
327
+ cy = r * (orig_h / myo_lab.shape[0])
328
+ ax.text(
329
+ cx, cy, f"M{prop.label}",
330
+ fontsize=font_myo,
331
+ color="white",
332
+ ha="center", va="center",
333
+ fontweight="bold",
334
+ bbox=dict(
335
+ boxstyle="round,pad=0.35",
336
+ fc="#8B0000", # solid dark-red β€” fully opaque
337
+ ec="#FF6666", # thin bright-red border so it pops
338
+ linewidth=1.5,
339
+ alpha=0.95,
340
+ ),
341
+ zorder=3,
342
+ )
343
 
344
+ # ── legend ────────────────────────────────────────────────────────────────
345
  patches = [
346
  mpatches.Patch(color=nuc_cmap(0.7), label=f"Nuclei (n={n_nuc})"),
347
  mpatches.Patch(color=myo_cmap(0.7), label=f"Myotubes (n={n_myo})"),
348
  ]
349
+ ax.legend(
350
+ handles=patches,
351
+ loc="upper right",
352
+ fontsize=13,
353
+ framealpha=0.85,
354
+ facecolor="#111111",
355
+ labelcolor="white",
356
+ edgecolor="#444444",
357
+ )
358
 
359
  fig.tight_layout(pad=0)
360
  buf = io.BytesIO()
361
+ # Save at same high DPI β€” this is what makes the PNG sharp when zoomed
362
  fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0, dpi=dpi)
363
  plt.close(fig)
364
  buf.seek(0)
 
527
  st.header("Postprocessing")
528
  min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
529
  min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
530
+ nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
531
  myo_close_radius = st.number_input("Myotube close radius", 0, 50, 3, 1)
532
 
533
  st.header("Watershed (nuclei splitting)")
 
630
  nuc_raw, myo_raw,
631
  min_nuc_area=int(min_nuc_area),
632
  min_myo_area=int(min_myo_area),
633
+ nuc_close_radius=int(nuc_close_radius),
634
  myo_close_radius=int(myo_close_radius),
635
  )
636
 
 
639
  rgb_u8, nuc_pp, myo_pp, nuc_rgb, myo_rgb, float(alpha)
640
  )
641
 
642
+ # Label maps β€” shared across all three overlays
643
  nuc_lab = label_nuclei_watershed(nuc_pp,
644
  min_distance=int(nuc_ws_min_dist),
645
  min_nuc_area=int(nuc_ws_min_area))
646
  myo_lab = label_cc(myo_pp)
647
+
648
+ # Combined instance overlay (both nuclei + myotubes)
649
  inst_ov = make_instance_overlay(rgb_u8, nuc_lab, myo_lab,
650
  alpha=float(alpha),
651
  label_nuclei=label_nuc,
652
  label_myotubes=label_myo)
653
 
654
+ # Nuclei-only overlay
655
+ nuc_only_ov = make_instance_overlay(rgb_u8, nuc_lab,
656
+ np.zeros_like(myo_lab),
657
+ alpha=float(alpha),
658
+ label_nuclei=True,
659
+ label_myotubes=False)
660
+
661
+ # Myotubes-only overlay
662
+ myo_only_ov = make_instance_overlay(rgb_u8,
663
+ np.zeros_like(nuc_lab),
664
+ myo_lab,
665
+ alpha=float(alpha),
666
+ label_nuclei=False,
667
+ label_myotubes=True)
668
+
669
  bio = compute_bio_metrics(
670
  nuc_pp, myo_pp,
671
  nuc_ws_min_distance=int(nuc_ws_min_dist),
 
678
  all_bio_metrics[name] = {**bio, "_per_myotube_areas": per_areas}
679
 
680
  artifacts[name] = {
681
+ "original" : png_bytes(rgb_u8),
682
+ "overlay" : png_bytes(inst_ov),
683
+ "nuc_only_ov" : png_bytes(nuc_only_ov),
684
+ "myo_only_ov" : png_bytes(myo_only_ov),
685
+ "nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
686
+ "myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
687
  }
688
 
689
  # ZIP contents
690
+ zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
691
+ zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_ov))
692
+ zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_ov))
693
+ zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_ov))
694
+ zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
695
+ zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
696
+ zf.writestr(f"{name}/nuclei_raw.png", png_bytes((nuc_raw*255).astype(np.uint8)))
697
+ zf.writestr(f"{name}/myotube_raw.png", png_bytes((myo_raw*255).astype(np.uint8)))
698
 
699
  prog.progress((i + 1) / len(uploads))
700
 
 
747
  col_img, col_metrics = st.columns([3, 2], gap="large")
748
 
749
  with col_img:
750
+ tabs = st.tabs([
751
+ "πŸ”΅ Combined overlay",
752
+ "🟣 Nuclei only",
753
+ "🟠 Myotubes only",
754
+ "πŸ“· Original",
755
+ "⬜ Nuclei mask",
756
+ "⬜ Myotube mask",
757
+ ])
758
+ art = st.session_state.artifacts[pick]
759
+ bio_cur = st.session_state.bio_metrics.get(pick, {})
760
  FIXED_W = 700
761
+ with tabs[0]:
762
+ st.image(art["overlay"], width=FIXED_W)
763
+ with tabs[1]:
764
+ n_nuc = bio_cur.get("total_nuclei", "β€”")
765
+ st.caption(f"**Nuclei count: {n_nuc}** β€” each nucleus has a unique ID label")
766
+ st.image(art["nuc_only_ov"], width=FIXED_W)
767
+ with tabs[2]:
768
+ n_myo = bio_cur.get("myotube_count", "β€”")
769
+ st.caption(f"**Myotube count: {n_myo}** β€” each myotube has a unique M-label")
770
+ st.image(art["myo_only_ov"], width=FIXED_W)
771
+ with tabs[3]:
772
+ st.image(art["original"], width=FIXED_W)
773
+ with tabs[4]:
774
+ st.image(art["nuc_pp"], width=FIXED_W)
775
+ with tabs[5]:
776
+ st.image(art["myo_pp"], width=FIXED_W)
777
 
778
  with col_metrics:
779
  st.markdown("#### πŸ“Š Live metrics")