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

Update app to V4

Browse files
Files changed (2) hide show
  1. src/streamlit_app.py +453 -144
  2. srcstreamlit_app_v3.py +0 -902
src/streamlit_app.py CHANGED
@@ -29,6 +29,7 @@ import pandas as pd
29
  from PIL import Image
30
 
31
  import streamlit as st
 
32
  import torch
33
  import torch.nn as nn
34
  import matplotlib
@@ -231,28 +232,20 @@ def make_simple_overlay(rgb_u8, nuc_mask, myo_mask, nuc_color, myo_color, alpha)
231
  return np.clip(out, 0, 255).astype(np.uint8)
232
 
233
 
234
- def make_instance_overlay(rgb_u8: np.ndarray,
235
  nuc_lab: np.ndarray,
236
  myo_lab: np.ndarray,
237
- alpha: float = 0.45,
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)
@@ -263,7 +256,6 @@ def make_instance_overlay(rgb_u8: np.ndarray,
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)
@@ -275,94 +267,397 @@ def make_instance_overlay(rgb_u8: np.ndarray,
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)
365
- return np.array(Image.open(buf).convert("RGB"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
 
368
  # ─────────────────────────────────────────────────────────────────────────────
@@ -634,37 +929,25 @@ if run:
634
  myo_close_radius=int(myo_close_radius),
635
  )
636
 
637
- # Flat overlay for ZIP
638
  simple_ov = make_simple_overlay(
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,
@@ -677,20 +960,30 @@ if run:
677
  results.append(bio)
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)))
@@ -748,7 +1041,7 @@ 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",
@@ -757,23 +1050,39 @@ with col_img:
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")
 
29
  from PIL import Image
30
 
31
  import streamlit as st
32
+ import streamlit.components.v1
33
  import torch
34
  import torch.nn as nn
35
  import matplotlib
 
232
  return np.clip(out, 0, 255).astype(np.uint8)
233
 
234
 
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")
247
  myo_cmap = plt.cm.get_cmap("autumn")
248
 
 
249
  def _resize_lab(lab, h, w):
250
  return np.array(
251
  Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
 
256
  n_nuc = int(nuc_disp.max())
257
  n_myo = int(myo_disp.max())
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)
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
+ def collect_label_positions(nuc_lab: np.ndarray,
275
+ myo_lab: np.ndarray,
276
+ img_w: int, img_h: int) -> dict:
277
+ """
278
+ Collect centroid positions for every nucleus and myotube,
279
+ scaled to the original image pixel dimensions.
280
+ Returns:
281
+ { "nuclei": [ {"id": 1, "x": 123.4, "y": 56.7}, ... ],
282
+ "myotubes": [ {"id": "M1","x": 200.1, "y": 300.5}, ... ] }
283
+ """
284
+ sx = img_w / nuc_lab.shape[1]
285
+ sy = img_h / nuc_lab.shape[0]
286
+
287
+ nuclei = []
288
+ for prop in measure.regionprops(nuc_lab):
289
+ r, c = prop.centroid
290
+ nuclei.append({"id": str(prop.label), "x": round(c * sx, 1), "y": round(r * sy, 1)})
291
+
292
+ sx2 = img_w / myo_lab.shape[1]
293
+ sy2 = img_h / myo_lab.shape[0]
294
+ myotubes = []
295
+ for prop in measure.regionprops(myo_lab):
296
+ r, c = prop.centroid
297
+ myotubes.append({"id": f"M{prop.label}", "x": round(c * sx2, 1), "y": round(r * sy2, 1)})
298
+
299
+ return {"nuclei": nuclei, "myotubes": myotubes}
300
+
301
+
302
+ def make_svg_viewer(img_b64: str,
303
+ img_w: int, img_h: int,
304
+ label_data: dict,
305
+ show_nuclei: bool = True,
306
+ show_myotubes: bool = True,
307
+ nuc_font_size: int = 11,
308
+ myo_font_size: int = 22,
309
+ viewer_height: int = 620) -> str:
310
+ """
311
+ Build a self-contained HTML string with:
312
+ - A pan-and-zoom SVG viewer (mouse wheel + click-drag)
313
+ - The coloured overlay PNG as the background
314
+ - SVG <text> labels that stay pixel-perfect at any zoom level
315
+ - A font-size slider that updates label sizes live
316
+ - Toggle buttons for nuclei / myotubes labels
317
+ - Count badges in the top-right corner
318
+
319
+ Parameters
320
+ ----------
321
+ img_b64 : base64-encoded PNG of the coloured overlay (no text)
322
+ img_w, img_h : original pixel dimensions of the image
323
+ label_data : output of collect_label_positions()
324
+ show_nuclei : initial visibility of nucleus labels
325
+ show_myotubes : initial visibility of myotube labels
326
+ nuc_font_size : initial nucleus label font size (px)
327
+ myo_font_size : initial myotube label font size (px)
328
+ viewer_height : height of the viewer div in pixels
329
+ """
330
+ import json as _json
331
+ labels_json = _json.dumps(label_data)
332
+ n_nuc = len(label_data.get("nuclei", []))
333
+ n_myo = len(label_data.get("myotubes", []))
334
+
335
+ show_nuc_js = "true" if show_nuclei else "false"
336
+ show_myo_js = "true" if show_myotubes else "false"
337
+
338
+ html = f"""
339
+ <style>
340
+ .myo-viewer-wrap {{
341
+ background: #0e0e1a;
342
+ border: 1px solid #2a2a4e;
343
+ border-radius: 10px;
344
+ overflow: hidden;
345
+ position: relative;
346
+ user-select: none;
347
+ }}
348
+ .myo-toolbar {{
349
+ display: flex;
350
+ align-items: center;
351
+ gap: 12px;
352
+ padding: 8px 14px;
353
+ background: #13132a;
354
+ border-bottom: 1px solid #2a2a4e;
355
+ flex-wrap: wrap;
356
+ }}
357
+ .myo-badge {{
358
+ background: #1a1a3e;
359
+ border: 1px solid #3a3a6e;
360
+ border-radius: 6px;
361
+ padding: 3px 10px;
362
+ color: #e0e0e0;
363
+ font-size: 13px;
364
+ font-family: Arial, sans-serif;
365
+ white-space: nowrap;
366
+ }}
367
+ .myo-badge span {{ font-weight: bold; }}
368
+ .myo-btn {{
369
+ padding: 4px 12px;
370
+ border-radius: 6px;
371
+ border: 1px solid #444;
372
+ cursor: pointer;
373
+ font-size: 12px;
374
+ font-family: Arial, sans-serif;
375
+ font-weight: bold;
376
+ transition: opacity 0.15s;
377
+ }}
378
+ .myo-btn.nuc {{ background: #003366; color: white; border-color: #4fc3f7; }}
379
+ .myo-btn.myo {{ background: #8B0000; color: white; border-color: #ff6666; }}
380
+ .myo-btn.off {{ opacity: 0.35; }}
381
+ .myo-btn.reset {{ background: #1a1a2e; color: #90caf9; border-color: #3a3a6e; }}
382
+ .myo-slider-wrap {{
383
+ display: flex;
384
+ align-items: center;
385
+ gap: 6px;
386
+ color: #aaa;
387
+ font-size: 12px;
388
+ font-family: Arial, sans-serif;
389
+ }}
390
+ .myo-slider-wrap input {{ width: 70px; accent-color: #4fc3f7; cursor: pointer; }}
391
+ .myo-hint {{
392
+ margin-left: auto;
393
+ color: #555;
394
+ font-size: 11px;
395
+ font-family: Arial, sans-serif;
396
+ white-space: nowrap;
397
+ }}
398
+ .myo-svg-wrap {{
399
+ width: 100%;
400
+ height: {viewer_height}px;
401
+ overflow: hidden;
402
+ cursor: grab;
403
+ position: relative;
404
+ }}
405
+ .myo-svg-wrap:active {{ cursor: grabbing; }}
406
+ svg.myo-svg {{
407
+ width: 100%;
408
+ height: 100%;
409
+ display: block;
410
+ }}
411
+ </style>
412
+
413
+ <div class="myo-viewer-wrap" id="myoViewer">
414
+ <div class="myo-toolbar">
415
+ <div class="myo-badge">πŸ”΅ Nuclei &nbsp;<span id="nucCount">{n_nuc}</span></div>
416
+ <div class="myo-badge">πŸ”΄ Myotubes &nbsp;<span id="myoCount">{n_myo}</span></div>
417
+ <button class="myo-btn nuc" id="btnNuc" onclick="toggleLayer('nuc')">Nuclei IDs</button>
418
+ <button class="myo-btn myo" id="btnMyo" onclick="toggleLayer('myo')">Myotube IDs</button>
419
+ <button class="myo-btn reset" onclick="resetView()">⟳ Reset</button>
420
+ <div class="myo-slider-wrap">
421
+ Nucleus size:
422
+ <input type="range" id="slNuc" min="4" max="40" value="{nuc_font_size}"
423
+ oninput="setFontSize('nuc', this.value)" />
424
+ <span id="lblNuc">{nuc_font_size}px</span>
425
+ </div>
426
+ <div class="myo-slider-wrap">
427
+ Myotube size:
428
+ <input type="range" id="slMyo" min="8" max="60" value="{myo_font_size}"
429
+ oninput="setFontSize('myo', this.value)" />
430
+ <span id="lblMyo">{myo_font_size}px</span>
431
+ </div>
432
+ <div class="myo-hint">Scroll to zoom &nbsp;Β·&nbsp; Drag to pan</div>
433
+ </div>
434
+
435
+ <div class="myo-svg-wrap" id="svgWrap">
436
+ <svg class="myo-svg" id="mainSvg"
437
+ viewBox="0 0 {img_w} {img_h}"
438
+ preserveAspectRatio="xMidYMid meet">
439
+ <defs>
440
+ <filter id="dropshadow" x="-5%" y="-5%" width="110%" height="110%">
441
+ <feDropShadow dx="0" dy="0" stdDeviation="1.5" flood-color="#000" flood-opacity="0.8"/>
442
+ </filter>
443
+ </defs>
444
+
445
+ <!-- background image β€” the coloured overlay PNG -->
446
+ <image href="data:image/png;base64,{img_b64}"
447
+ x="0" y="0" width="{img_w}" height="{img_h}"
448
+ preserveAspectRatio="xMidYMid meet"/>
449
+
450
+ <!-- nuclei labels group -->
451
+ <g id="gNuc" visibility="{'visible' if show_nuclei else 'hidden'}">
452
+ </g>
453
+
454
+ <!-- myotube labels group -->
455
+ <g id="gMyo" visibility="{'visible' if show_myotubes else 'hidden'}">
456
+ </g>
457
+ </svg>
458
+ </div>
459
+ </div>
460
+
461
+ <script>
462
+ (function() {{
463
+ const labels = {labels_json};
464
+ const IMG_W = {img_w};
465
+ const IMG_H = {img_h};
466
+
467
+ let nucFontSize = {nuc_font_size};
468
+ let myoFontSize = {myo_font_size};
469
+ let showNuc = {show_nuc_js};
470
+ let showMyo = {show_myo_js};
471
+
472
+ // ── Build SVG label elements ─────────────────────────────────────────────
473
+ const NS = "http://www.w3.org/2000/svg";
474
+
475
+ function makeLabelGroup(items, fontSize, bgColor, borderColor, isMyo) {{
476
+ const frag = document.createDocumentFragment();
477
+ items.forEach(item => {{
478
+ const g = document.createElementNS(NS, "g");
479
+ g.setAttribute("class", isMyo ? "lbl-myo" : "lbl-nuc");
480
+
481
+ // Background rect β€” sized after text is measured
482
+ const rect = document.createElementNS(NS, "rect");
483
+ rect.setAttribute("rx", isMyo ? "4" : "3");
484
+ rect.setAttribute("ry", isMyo ? "4" : "3");
485
+ rect.setAttribute("fill", bgColor);
486
+ rect.setAttribute("stroke", borderColor);
487
+ rect.setAttribute("stroke-width", isMyo ? "1.5" : "0");
488
+ rect.setAttribute("opacity", isMyo ? "0.93" : "0.90");
489
+ rect.setAttribute("filter", "url(#dropshadow)");
490
+
491
+ // Text
492
+ const txt = document.createElementNS(NS, "text");
493
+ txt.textContent = item.id;
494
+ txt.setAttribute("x", item.x);
495
+ txt.setAttribute("y", item.y);
496
+ txt.setAttribute("text-anchor", "middle");
497
+ txt.setAttribute("dominant-baseline", "central");
498
+ txt.setAttribute("fill", "white");
499
+ txt.setAttribute("font-family", "Arial, sans-serif");
500
+ txt.setAttribute("font-weight", "bold");
501
+ txt.setAttribute("font-size", fontSize);
502
+ txt.setAttribute("paint-order", "stroke");
503
+
504
+ g.appendChild(rect);
505
+ g.appendChild(txt);
506
+ frag.appendChild(g);
507
+ }});
508
+ return frag;
509
+ }}
510
+
511
+ function positionRects() {{
512
+ // After elements are in the DOM, size and position the backing rects
513
+ document.querySelectorAll(".lbl-nuc, .lbl-myo").forEach(g => {{
514
+ const txt = g.querySelector("text");
515
+ const rect = g.querySelector("rect");
516
+ try {{
517
+ const bb = txt.getBBox();
518
+ const pad = parseFloat(txt.getAttribute("font-size")) * 0.22;
519
+ rect.setAttribute("x", bb.x - pad);
520
+ rect.setAttribute("y", bb.y - pad);
521
+ rect.setAttribute("width", bb.width + pad * 2);
522
+ rect.setAttribute("height", bb.height + pad * 2);
523
+ }} catch(e) {{}}
524
+ }});
525
+ }}
526
+
527
+ function rebuildLabels() {{
528
+ const gNuc = document.getElementById("gNuc");
529
+ const gMyo = document.getElementById("gMyo");
530
+ gNuc.innerHTML = "";
531
+ gMyo.innerHTML = "";
532
+ gNuc.appendChild(makeLabelGroup(labels.nuclei, nucFontSize, "#003366", "none", false));
533
+ gMyo.appendChild(makeLabelGroup(labels.myotubes, myoFontSize, "#8B0000", "#FF6666", true));
534
+ // rAF so the browser has laid out the text before we measure it
535
+ requestAnimationFrame(positionRects);
536
+ }}
537
+
538
+ // ── Font size controls ────────────────────────────────────────────────────
539
+ window.setFontSize = function(which, val) {{
540
+ val = parseInt(val);
541
+ if (which === "nuc") {{
542
+ nucFontSize = val;
543
+ document.getElementById("lblNuc").textContent = val + "px";
544
+ document.querySelectorAll(".lbl-nuc text").forEach(t => t.setAttribute("font-size", val));
545
+ }} else {{
546
+ myoFontSize = val;
547
+ document.getElementById("lblMyo").textContent = val + "px";
548
+ document.querySelectorAll(".lbl-myo text").forEach(t => t.setAttribute("font-size", val));
549
+ }}
550
+ requestAnimationFrame(positionRects);
551
+ }};
552
+
553
+ // ── Layer toggles ─────────────────────────────────────────────────────────
554
+ window.toggleLayer = function(which) {{
555
+ if (which === "nuc") {{
556
+ showNuc = !showNuc;
557
+ document.getElementById("gNuc").setAttribute("visibility", showNuc ? "visible" : "hidden");
558
+ document.getElementById("btnNuc").classList.toggle("off", !showNuc);
559
+ }} else {{
560
+ showMyo = !showMyo;
561
+ document.getElementById("gMyo").setAttribute("visibility", showMyo ? "visible" : "hidden");
562
+ document.getElementById("btnMyo").classList.toggle("off", !showMyo);
563
+ }}
564
+ }};
565
+
566
+ // ── Pan + Zoom (pure SVG viewBox manipulation) ────────────────────────────
567
+ const wrap = document.getElementById("svgWrap");
568
+ const svg = document.getElementById("mainSvg");
569
+
570
+ let vx = 0, vy = 0, vw = IMG_W, vh = IMG_H; // current viewBox
571
+
572
+ function setVB() {{
573
+ svg.setAttribute("viewBox", `${{vx}} ${{vy}} ${{vw}} ${{vh}}`);
574
+ }}
575
+
576
+ // Scroll to zoom β€” zoom toward mouse cursor
577
+ wrap.addEventListener("wheel", e => {{
578
+ e.preventDefault();
579
+ const rect = wrap.getBoundingClientRect();
580
+ const mx = (e.clientX - rect.left) / rect.width; // 0..1
581
+ const my = (e.clientY - rect.top) / rect.height;
582
+ const factor = e.deltaY < 0 ? 0.85 : 1.0 / 0.85;
583
+ const nw = Math.min(IMG_W, Math.max(IMG_W * 0.05, vw * factor));
584
+ const nh = Math.min(IMG_H, Math.max(IMG_H * 0.05, vh * factor));
585
+ vx = vx + mx * (vw - nw);
586
+ vy = vy + my * (vh - nh);
587
+ vw = nw;
588
+ vh = nh;
589
+ // Clamp
590
+ vx = Math.max(0, Math.min(IMG_W - vw, vx));
591
+ vy = Math.max(0, Math.min(IMG_H - vh, vy));
592
+ setVB();
593
+ }}, {{ passive: false }});
594
+
595
+ // Drag to pan
596
+ let dragging = false, dragX0, dragY0, vx0, vy0;
597
+
598
+ wrap.addEventListener("mousedown", e => {{
599
+ dragging = true;
600
+ dragX0 = e.clientX; dragY0 = e.clientY;
601
+ vx0 = vx; vy0 = vy;
602
+ }});
603
+ window.addEventListener("mousemove", e => {{
604
+ if (!dragging) return;
605
+ const rect = wrap.getBoundingClientRect();
606
+ const scaleX = vw / rect.width;
607
+ const scaleY = vh / rect.height;
608
+ vx = Math.max(0, Math.min(IMG_W - vw, vx0 - (e.clientX - dragX0) * scaleX));
609
+ vy = Math.max(0, Math.min(IMG_H - vh, vy0 - (e.clientY - dragY0) * scaleY));
610
+ setVB();
611
+ }});
612
+ window.addEventListener("mouseup", () => {{ dragging = false; }});
613
+
614
+ // Touch support
615
+ let t0 = null, pinch0 = null;
616
+ wrap.addEventListener("touchstart", e => {{
617
+ if (e.touches.length === 1) {{
618
+ t0 = e.touches[0]; vx0 = vx; vy0 = vy;
619
+ }} else if (e.touches.length === 2) {{
620
+ pinch0 = Math.hypot(
621
+ e.touches[0].clientX - e.touches[1].clientX,
622
+ e.touches[0].clientY - e.touches[1].clientY
623
+ );
624
+ }}
625
+ }}, {{ passive: true }});
626
+ wrap.addEventListener("touchmove", e => {{
627
+ e.preventDefault();
628
+ if (e.touches.length === 1 && t0) {{
629
+ const rect = wrap.getBoundingClientRect();
630
+ vx = Math.max(0, Math.min(IMG_W - vw, vx0 - (e.touches[0].clientX - t0.clientX) * vw / rect.width));
631
+ vy = Math.max(0, Math.min(IMG_H - vh, vy0 - (e.touches[0].clientY - t0.clientY) * vh / rect.height));
632
+ setVB();
633
+ }} else if (e.touches.length === 2 && pinch0 !== null) {{
634
+ const dist = Math.hypot(
635
+ e.touches[0].clientX - e.touches[1].clientX,
636
+ e.touches[0].clientY - e.touches[1].clientY
637
+ );
638
+ const factor = pinch0 / dist;
639
+ const nw = Math.min(IMG_W, Math.max(IMG_W * 0.05, vw * factor));
640
+ const nh = Math.min(IMG_H, Math.max(IMG_H * 0.05, vh * factor));
641
+ vw = nw; vh = nh;
642
+ vx = Math.max(0, Math.min(IMG_W - vw, vx));
643
+ vy = Math.max(0, Math.min(IMG_H - vh, vy));
644
+ pinch0 = dist;
645
+ setVB();
646
+ }}
647
+ }}, {{ passive: false }});
648
+
649
+ // ── Reset view ────────────────────────────────────────────────────────────
650
+ window.resetView = function() {{
651
+ vx = 0; vy = 0; vw = IMG_W; vh = IMG_H;
652
+ setVB();
653
+ }};
654
+
655
+ // ── Init ──────────────────────────────────────────────────────────────────
656
+ rebuildLabels();
657
+ }})();
658
+ </script>
659
+ """
660
+ return html
661
 
662
 
663
  # ─────────────────────────────────────────────────────────────────────────────
 
929
  myo_close_radius=int(myo_close_radius),
930
  )
931
 
932
+ # Flat overlay for ZIP (no labels β€” just colour regions)
933
  simple_ov = make_simple_overlay(
934
  rgb_u8, nuc_pp, myo_pp, nuc_rgb, myo_rgb, float(alpha)
935
  )
936
 
937
+ # Label maps β€” shared across all three viewers
938
  nuc_lab = label_nuclei_watershed(nuc_pp,
939
  min_distance=int(nuc_ws_min_dist),
940
  min_nuc_area=int(nuc_ws_min_area))
941
  myo_lab = label_cc(myo_pp)
942
 
943
+ # Coloured pixel overlays (no baked-in text β€” labels drawn as SVG)
944
+ inst_px = make_coloured_overlay(rgb_u8, nuc_lab, myo_lab, alpha=float(alpha))
945
+ nuc_only_px = make_coloured_overlay(rgb_u8, nuc_lab, np.zeros_like(myo_lab), alpha=float(alpha))
946
+ myo_only_px = make_coloured_overlay(rgb_u8, np.zeros_like(nuc_lab), myo_lab, alpha=float(alpha))
947
+
948
+ # Label positions in image-pixel coordinates (used by SVG viewer)
949
+ orig_h_img, orig_w_img = rgb_u8.shape[:2]
950
+ label_positions = collect_label_positions(nuc_lab, myo_lab, orig_w_img, orig_h_img)
 
 
 
 
 
 
 
 
 
 
 
 
951
 
952
  bio = compute_bio_metrics(
953
  nuc_pp, myo_pp,
 
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
978
+ "img_w" : orig_w_img,
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)))
 
1041
 
1042
  with col_img:
1043
  tabs = st.tabs([
1044
+ "πŸ”΅ Combined",
1045
  "🟣 Nuclei only",
1046
  "🟠 Myotubes only",
1047
  "πŸ“· Original",
 
1050
  ])
1051
  art = st.session_state.artifacts[pick]
1052
  bio_cur = st.session_state.bio_metrics.get(pick, {})
1053
+ lpos = art["label_positions"]
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)
1063
+
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)
1071
+
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]:
1085
+ st.image(art["myo_pp"], use_container_width=True)
1086
 
1087
  with col_metrics:
1088
  st.markdown("#### πŸ“Š Live metrics")
srcstreamlit_app_v3.py DELETED
@@ -1,902 +0,0 @@
1
- # src/streamlit_app.py
2
- """
3
- MyoSight β€” Myotube & Nuclei Analyser
4
- ========================================
5
- Drop-in replacement for streamlit_app.py on Hugging Face Spaces.
6
-
7
- 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
- """
17
-
18
- import io
19
- import os
20
- import json
21
- import time
22
- import zipfile
23
- import hashlib
24
- from datetime import datetime
25
- from pathlib import Path
26
-
27
- import numpy as np
28
- import pandas as pd
29
- from PIL import Image
30
-
31
- import streamlit as st
32
- import torch
33
- import torch.nn as nn
34
- import matplotlib
35
- matplotlib.use("Agg")
36
- import matplotlib.pyplot as plt
37
- import matplotlib.patches as mpatches
38
- from huggingface_hub import hf_hub_download
39
-
40
- import scipy.ndimage as ndi
41
- from skimage.morphology import remove_small_objects, disk, closing, opening
42
- from skimage import measure
43
- from skimage.segmentation import watershed
44
- from skimage.feature import peak_local_max
45
-
46
-
47
- # ─────────────────────────────────────────────────────────────────────────────
48
- # CONFIG ← edit these two lines to match your HF model repo
49
- # ─────────────────────────────────────────────────────────────────────────────
50
- MODEL_REPO_ID = "skarugu/myotube-unet"
51
- MODEL_FILENAME = "model_final.pt"
52
-
53
- CONF_FLAG_THR = 0.60 # images below this confidence are queued for retraining
54
- QUEUE_DIR = Path("retrain_queue")
55
- CORRECTIONS_DIR = Path("corrections")
56
-
57
-
58
- # ─────────────────────────────────────────────────────────────────────────────
59
- # Helpers (identical to originals so nothing breaks)
60
- # ─────────────────────────────────────────────────────────────────────────────
61
-
62
- def sha256_file(path: str) -> str:
63
- h = hashlib.sha256()
64
- with open(path, "rb") as f:
65
- for chunk in iter(lambda: f.read(1024 * 1024), b""):
66
- h.update(chunk)
67
- return h.hexdigest()
68
-
69
-
70
- def png_bytes(arr_u8: np.ndarray) -> bytes:
71
- buf = io.BytesIO()
72
- Image.fromarray(arr_u8).save(buf, format="PNG")
73
- return buf.getvalue()
74
-
75
-
76
- def resize_u8_to_float01(ch_u8: np.ndarray, W: int, H: int,
77
- resample=Image.BILINEAR) -> np.ndarray:
78
- im = Image.fromarray(ch_u8, mode="L").resize((W, H), resample=resample)
79
- return np.array(im, dtype=np.float32) / 255.0
80
-
81
-
82
- def get_channel(rgb_u8: np.ndarray, source: str) -> np.ndarray:
83
- if source == "Red": return rgb_u8[..., 0]
84
- if source == "Green": return rgb_u8[..., 1]
85
- if source == "Blue": return rgb_u8[..., 2]
86
- return (0.299*rgb_u8[...,0] + 0.587*rgb_u8[...,1] + 0.114*rgb_u8[...,2]).astype(np.uint8)
87
-
88
-
89
- def hex_to_rgb(h: str):
90
- h = h.lstrip("#")
91
- return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
92
-
93
-
94
- # ─────────────────────────────────────────────────────────────────────────────
95
- # Postprocessing
96
- # ─────────────────────────────────────────────────────────────────────────────
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)
116
- myo_clean = remove_small_objects(myo_bin, min_size=int(min_myo_area)).astype(np.uint8)
117
-
118
- return nuc_clean, myo_clean
119
-
120
-
121
- def label_cc(mask: np.ndarray) -> np.ndarray:
122
- lab, _ = ndi.label(mask.astype(np.uint8))
123
- return lab
124
-
125
-
126
- def label_nuclei_watershed(nuc_bin: np.ndarray,
127
- min_distance: int = 3,
128
- min_nuc_area: int = 6) -> np.ndarray:
129
- """Split touching nuclei via distance-transform watershed."""
130
- nuc_bin = remove_small_objects(nuc_bin.astype(bool), min_size=min_nuc_area)
131
- if nuc_bin.sum() == 0:
132
- return np.zeros_like(nuc_bin, dtype=np.int32)
133
-
134
- dist = ndi.distance_transform_edt(nuc_bin)
135
- coords = peak_local_max(dist, labels=nuc_bin,
136
- min_distance=min_distance, exclude_border=False)
137
- markers = np.zeros_like(nuc_bin, dtype=np.int32)
138
- for i, (r, c) in enumerate(coords, start=1):
139
- markers[r, c] = i
140
-
141
- if markers.max() == 0:
142
- return ndi.label(nuc_bin.astype(np.uint8))[0].astype(np.int32)
143
-
144
- return watershed(-dist, markers, mask=nuc_bin).astype(np.int32)
145
-
146
-
147
- # ─────────────────────────────────────────────────────────────────────────────
148
- # Surface area (new)
149
- # ─────────────────────────────────────────────────────────────────────────────
150
-
151
- def compute_surface_area(myo_mask: np.ndarray, px_um: float = 1.0) -> dict:
152
- lab = label_cc(myo_mask)
153
- px_area = px_um ** 2
154
- per = [round(prop.area * px_area, 2) for prop in measure.regionprops(lab)]
155
- return {
156
- "total_area_um2" : round(sum(per), 2),
157
- "mean_area_um2" : round(float(np.mean(per)) if per else 0.0, 2),
158
- "max_area_um2" : round(float(np.max(per)) if per else 0.0, 2),
159
- "per_myotube_areas" : per,
160
- }
161
-
162
-
163
- # ─────────────────────────────────────────────────────────────────────────────
164
- # Biological metrics (counting + fusion + surface area)
165
- # ─────────────────────────────────────────────────────────────────────────────
166
-
167
- def compute_bio_metrics(nuc_mask, myo_mask,
168
- min_overlap_frac=0.1,
169
- nuc_ws_min_distance=3,
170
- nuc_ws_min_area=6,
171
- px_um=1.0) -> dict:
172
- nuc_lab = label_nuclei_watershed(nuc_mask,
173
- min_distance=nuc_ws_min_distance,
174
- min_nuc_area=nuc_ws_min_area)
175
- myo_lab = label_cc(myo_mask)
176
- total = int(nuc_lab.max())
177
-
178
- pos, nm = 0, {}
179
- for prop in measure.regionprops(nuc_lab):
180
- coords = prop.coords
181
- ids = myo_lab[coords[:, 0], coords[:, 1]]
182
- ids = ids[ids > 0]
183
- if ids.size == 0:
184
- continue
185
- unique, counts = np.unique(ids, return_counts=True)
186
- mt = int(unique[np.argmax(counts)])
187
- frac = counts.max() / len(coords)
188
- if frac >= min_overlap_frac:
189
- pos += 1
190
- nm.setdefault(mt, []).append(prop.label)
191
-
192
- per = [len(v) for v in nm.values()]
193
- fused = sum(n for n in per if n >= 2)
194
- fi = 100.0 * fused / total if total else 0.0
195
- pct = 100.0 * pos / total if total else 0.0
196
- avg = float(np.mean(per)) if per else 0.0
197
-
198
- sa = compute_surface_area(myo_mask, px_um=px_um)
199
-
200
- return {
201
- "total_nuclei" : total,
202
- "myHC_positive_nuclei" : int(pos),
203
- "myHC_positive_percentage" : round(pct, 2),
204
- "nuclei_fused" : int(fused),
205
- "myotube_count" : int(len(per)),
206
- "avg_nuclei_per_myotube" : round(avg, 2),
207
- "fusion_index" : round(fi, 2),
208
- "total_area_um2" : sa["total_area_um2"],
209
- "mean_area_um2" : sa["mean_area_um2"],
210
- "max_area_um2" : sa["max_area_um2"],
211
- "_per_myotube_areas" : sa["per_myotube_areas"], # _ prefix = kept out of CSV
212
- }
213
-
214
-
215
- # ─────────────────────────────────────────────────────────────────────────────
216
- # Overlay helpers
217
- # ─────────────────────────────────────────────────────────────────────────────
218
-
219
- def make_simple_overlay(rgb_u8, nuc_mask, myo_mask, nuc_color, myo_color, alpha):
220
- """Flat colour overlay β€” used for the ZIP export (fast, no matplotlib)."""
221
- base = rgb_u8.astype(np.float32)
222
- H0, W0 = rgb_u8.shape[:2]
223
- nuc = np.array(Image.fromarray((nuc_mask*255).astype(np.uint8))
224
- .resize((W0, H0), Image.NEAREST)) > 0
225
- myo = np.array(Image.fromarray((myo_mask*255).astype(np.uint8))
226
- .resize((W0, H0), Image.NEAREST)) > 0
227
- out = base.copy()
228
- for mask, color in [(myo, myo_color), (nuc, nuc_color)]:
229
- c = np.array(color, dtype=np.float32)
230
- out[mask] = (1 - alpha) * out[mask] + alpha * c
231
- return np.clip(out, 0, 255).astype(np.uint8)
232
-
233
-
234
- def make_instance_overlay(rgb_u8: np.ndarray,
235
- nuc_lab: np.ndarray,
236
- myo_lab: np.ndarray,
237
- alpha: float = 0.45,
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)
365
- return np.array(Image.open(buf).convert("RGB"))
366
-
367
-
368
- # ─────────────────────────────────────────────────────────────────────────────
369
- # Animated counter
370
- # ─────────────────────────────────────────────────────────────────────────────
371
-
372
- def animated_metric(placeholder, label: str, final_val,
373
- color: str = "#4fc3f7", steps: int = 20, delay: float = 0.025):
374
- is_float = isinstance(final_val, float)
375
- for i in range(1, steps + 1):
376
- v = final_val * i / steps
377
- display = f"{v:.1f}" if is_float else str(int(v))
378
- placeholder.markdown(
379
- f"""
380
- <div style='text-align:center;padding:12px 6px;border-radius:12px;
381
- background:#1a1a2e;border:1px solid #2a2a4e;margin:4px 0;'>
382
- <div style='font-size:2rem;font-weight:800;color:{color};
383
- line-height:1.1;'>{display}</div>
384
- <div style='font-size:0.75rem;color:#9e9e9e;margin-top:4px;'>{label}</div>
385
- </div>
386
- """,
387
- unsafe_allow_html=True,
388
- )
389
- time.sleep(delay)
390
-
391
-
392
- # ─────────────────────────────────────────────────────────────────────────────
393
- # Active-learning queue helpers
394
- # ─────────────────────────────────────────────────────────────────────────────
395
-
396
- def _ensure_dirs():
397
- QUEUE_DIR.mkdir(parents=True, exist_ok=True)
398
- CORRECTIONS_DIR.mkdir(parents=True, exist_ok=True)
399
-
400
-
401
- def add_to_queue(image_array: np.ndarray, reason: str = "batch",
402
- nuc_mask=None, myo_mask=None, metadata: dict = None):
403
- _ensure_dirs()
404
- ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
405
- meta = {**(metadata or {}), "reason": reason, "timestamp": ts}
406
-
407
- if nuc_mask is not None and myo_mask is not None:
408
- folder = CORRECTIONS_DIR / ts
409
- folder.mkdir(parents=True, exist_ok=True)
410
- Image.fromarray(image_array).save(folder / "image.png")
411
- Image.fromarray((nuc_mask > 0).astype(np.uint8) * 255).save(folder / "nuclei_mask.png")
412
- Image.fromarray((myo_mask > 0).astype(np.uint8) * 255).save(folder / "myotube_mask.png")
413
- (folder / "meta.json").write_text(json.dumps({**meta, "has_masks": True}, indent=2))
414
- else:
415
- Image.fromarray(image_array).save(QUEUE_DIR / f"{ts}.png")
416
- (QUEUE_DIR / f"{ts}.json").write_text(json.dumps({**meta, "has_masks": False}, indent=2))
417
-
418
-
419
- # ─────────────────────────────────────────────────────────────────────────────
420
- # Model (architecture identical to training script)
421
- # ─────────────────────────────────────────────────────────────────────────────
422
-
423
- class DoubleConv(nn.Module):
424
- def __init__(self, in_ch, out_ch):
425
- super().__init__()
426
- self.net = nn.Sequential(
427
- nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
428
- nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(True),
429
- )
430
- def forward(self, x): return self.net(x)
431
-
432
-
433
- class UNet(nn.Module):
434
- def __init__(self, in_ch=2, out_ch=2, base=32):
435
- super().__init__()
436
- self.d1 = DoubleConv(in_ch, base); self.p1 = nn.MaxPool2d(2)
437
- self.d2 = DoubleConv(base, base*2); self.p2 = nn.MaxPool2d(2)
438
- self.d3 = DoubleConv(base*2, base*4); self.p3 = nn.MaxPool2d(2)
439
- self.d4 = DoubleConv(base*4, base*8); self.p4 = nn.MaxPool2d(2)
440
- self.bn = DoubleConv(base*8, base*16)
441
- self.u4 = nn.ConvTranspose2d(base*16, base*8, 2, 2); self.du4 = DoubleConv(base*16, base*8)
442
- self.u3 = nn.ConvTranspose2d(base*8, base*4, 2, 2); self.du3 = DoubleConv(base*8, base*4)
443
- self.u2 = nn.ConvTranspose2d(base*4, base*2, 2, 2); self.du2 = DoubleConv(base*4, base*2)
444
- self.u1 = nn.ConvTranspose2d(base*2, base, 2, 2); self.du1 = DoubleConv(base*2, base)
445
- self.out = nn.Conv2d(base, out_ch, 1)
446
-
447
- def forward(self, x):
448
- d1=self.d1(x); p1=self.p1(d1)
449
- d2=self.d2(p1); p2=self.p2(d2)
450
- d3=self.d3(p2); p3=self.p3(d3)
451
- d4=self.d4(p3); p4=self.p4(d4)
452
- b=self.bn(p4)
453
- x=self.u4(b); x=torch.cat([x,d4],1); x=self.du4(x)
454
- x=self.u3(x); x=torch.cat([x,d3],1); x=self.du3(x)
455
- x=self.u2(x); x=torch.cat([x,d2],1); x=self.du2(x)
456
- x=self.u1(x); x=torch.cat([x,d1],1); x=self.du1(x)
457
- return self.out(x)
458
-
459
-
460
- @st.cache_resource
461
- def load_model(device: str):
462
- local = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILENAME,
463
- force_download=True)
464
- file_sha = sha256_file(local)
465
- mtime = time.ctime(os.path.getmtime(local))
466
- size_mb = os.path.getsize(local) / 1e6
467
-
468
- st.sidebar.markdown("### πŸ” Model debug")
469
- st.sidebar.caption(f"Repo: `{MODEL_REPO_ID}`")
470
- st.sidebar.caption(f"File: `{MODEL_FILENAME}`")
471
- st.sidebar.caption(f"Size: {size_mb:.2f} MB")
472
- st.sidebar.caption(f"Modified: {mtime}")
473
- st.sidebar.caption(f"SHA256: `{file_sha[:20]}…`")
474
-
475
- ckpt = torch.load(local, map_location=device)
476
- state = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt
477
- model = UNet(in_ch=2, out_ch=2, base=32)
478
- model.load_state_dict(state)
479
- model.to(device).eval()
480
- return model
481
-
482
-
483
- # ─────────────────────────────────────────────────────────────────────────────
484
- # PAGE CONFIG + CSS
485
- # ─────────────────────────────────────────────────────────────────────────────
486
-
487
- st.set_page_config(page_title="MyoSight β€” Myotube Analyser",
488
- layout="wide", page_icon="πŸ”¬")
489
-
490
- st.markdown("""
491
- <style>
492
- body, .stApp { background:#0e0e1a; color:#e0e0e0; }
493
- .block-container { max-width:1200px; padding-top:1.25rem; }
494
- h1,h2,h3,h4 { color:#90caf9; }
495
- .flag-box {
496
- background:#3e1a1a; border-left:4px solid #ef5350;
497
- padding:10px 16px; border-radius:8px; margin:8px 0;
498
- }
499
- </style>
500
- """, unsafe_allow_html=True)
501
-
502
- st.title("πŸ”¬ MyoSight β€” Myotube & Nuclei Analyser")
503
- device = "cuda" if torch.cuda.is_available() else "cpu"
504
-
505
- # ─────────────────────────────────────────────────────────────────────────────
506
- # SIDEBAR
507
- # ─────────────────────────────────────────────────────────────────────────────
508
- with st.sidebar:
509
- st.caption(f"Device: **{device}**")
510
-
511
- st.header("Input mapping")
512
- src1 = st.selectbox("Model channel 1 (MyHC / myotubes)",
513
- ["Red", "Green", "Blue", "Grayscale"], index=0)
514
- inv1 = st.checkbox("Invert channel 1", value=False)
515
- src2 = st.selectbox("Model channel 2 (DAPI / nuclei)",
516
- ["Red", "Green", "Blue", "Grayscale"], index=2)
517
- inv2 = st.checkbox("Invert channel 2", value=False)
518
-
519
- st.header("Preprocessing")
520
- image_size = st.select_slider("Model input size",
521
- options=[256, 384, 512, 640, 768, 1024], value=512)
522
-
523
- st.header("Thresholds")
524
- thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.50, 0.01)
525
- thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.50, 0.01)
526
-
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)")
534
- nuc_ws_min_dist = st.number_input("Min watershed distance", 1, 30, 3, 1)
535
- nuc_ws_min_area = st.number_input("Min watershed area (px)", 1, 500, 6, 1)
536
-
537
- st.header("Overlay")
538
- nuc_hex = st.color_picker("Nuclei colour", "#00FFFF")
539
- myo_hex = st.color_picker("Myotube colour", "#FF0000")
540
- alpha = st.slider("Overlay alpha", 0.0, 1.0, 0.45, 0.01)
541
- nuc_rgb = hex_to_rgb(nuc_hex)
542
- myo_rgb = hex_to_rgb(myo_hex)
543
- label_nuc = st.checkbox("Show nucleus IDs on overlay", value=True)
544
- label_myo = st.checkbox("Show myotube IDs on overlay", value=True)
545
-
546
- st.header("Surface area")
547
- px_um = st.number_input("Pixel size (Β΅m) β€” set for real Β΅mΒ²",
548
- value=1.0, min_value=0.01, step=0.01)
549
-
550
- st.header("Active learning")
551
- enable_al = st.toggle("Enable correction upload", value=True)
552
-
553
- st.header("Metric definitions")
554
- with st.expander("Fusion Index"):
555
- st.write("100 Γ— (nuclei in myotubes with β‰₯2 nuclei) / total nuclei")
556
- with st.expander("MyHC-positive nucleus"):
557
- st.write("Counted if β‰₯10% of nucleus pixels overlap a myotube.")
558
- with st.expander("Surface area"):
559
- st.write("Pixel count Γ— px_umΒ². Set pixel size for real Β΅mΒ² values.")
560
-
561
-
562
- # ─────────────────────────────────────────────────────────────────────────────
563
- # FILE UPLOADER
564
- # ─────────────────────────────────────────────────────────────────────────────
565
- uploads = st.file_uploader(
566
- "Upload 1+ images (png / jpg / tif). Public Space β€” don't upload sensitive data.",
567
- type=["png", "jpg", "jpeg", "tif", "tiff"],
568
- accept_multiple_files=True,
569
- )
570
-
571
- for key in ("df", "artifacts", "zip_bytes", "bio_metrics"):
572
- if key not in st.session_state:
573
- st.session_state[key] = None
574
-
575
- if not uploads:
576
- st.info("πŸ‘† Upload one or more fluorescence images to get started.")
577
- st.stop()
578
-
579
- model = load_model(device=device)
580
-
581
- # ─────────────────────────────────────────────────────────────────────────────
582
- # RUN ANALYSIS
583
- # ─────────────────────────────────────────────────────────────────────────────
584
- with st.form("run_form"):
585
- run = st.form_submit_button("β–Ά Run / Rerun analysis", type="primary")
586
-
587
- if run:
588
- results = []
589
- artifacts = {}
590
- all_bio_metrics = {}
591
- low_conf_flags = []
592
- zip_buf = io.BytesIO()
593
-
594
- with st.spinner("Analysing images…"):
595
- with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
596
- prog = st.progress(0.0)
597
-
598
- for i, up in enumerate(uploads):
599
- name = Path(up.name).stem
600
- rgb_u8 = np.array(
601
- Image.open(io.BytesIO(up.getvalue())).convert("RGB"),
602
- dtype=np.uint8
603
- )
604
-
605
- ch1 = get_channel(rgb_u8, src1)
606
- ch2 = get_channel(rgb_u8, src2)
607
- if inv1: ch1 = 255 - ch1
608
- if inv2: ch2 = 255 - ch2
609
-
610
- H = W = int(image_size)
611
- x1 = resize_u8_to_float01(ch1, W, H, Image.BILINEAR)
612
- x2 = resize_u8_to_float01(ch2, W, H, Image.BILINEAR)
613
- x = np.stack([x1, x2], 0).astype(np.float32)
614
-
615
- x_t = torch.from_numpy(x).unsqueeze(0).to(device)
616
- with torch.no_grad():
617
- probs = torch.sigmoid(model(x_t)).cpu().numpy()[0]
618
-
619
- # Confidence check
620
- conf = float(np.mean([probs[0].max(), probs[1].max()]))
621
- if conf < CONF_FLAG_THR:
622
- low_conf_flags.append((name, conf))
623
- add_to_queue(rgb_u8, reason="low_confidence",
624
- metadata={"confidence": conf, "filename": up.name})
625
-
626
- nuc_raw = (probs[0] > float(thr_nuc)).astype(np.uint8)
627
- myo_raw = (probs[1] > float(thr_myo)).astype(np.uint8)
628
-
629
- nuc_pp, myo_pp = postprocess_masks(
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
-
637
- # Flat overlay for ZIP
638
- simple_ov = make_simple_overlay(
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),
672
- nuc_ws_min_area=int(nuc_ws_min_area),
673
- px_um=float(px_um),
674
- )
675
- per_areas = bio.pop("_per_myotube_areas", [])
676
- bio["image"] = name
677
- results.append(bio)
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
-
701
- df = pd.DataFrame(results).sort_values("image")
702
- zf.writestr("metrics.csv", df.to_csv(index=False).encode("utf-8"))
703
-
704
- st.session_state.df = df
705
- st.session_state.artifacts = artifacts
706
- st.session_state.zip_bytes = zip_buf.getvalue()
707
- st.session_state.bio_metrics = all_bio_metrics
708
-
709
- if low_conf_flags:
710
- names_str = ", ".join(f"{n} (conf={c:.2f})" for n, c in low_conf_flags)
711
- st.markdown(
712
- f"<div class='flag-box'>⚠️ <b>Low-confidence images auto-queued for retraining:</b> "
713
- f"{names_str}</div>",
714
- unsafe_allow_html=True,
715
- )
716
-
717
- if st.session_state.df is None:
718
- st.info("Click **β–Ά Run / Rerun analysis** to generate results.")
719
- st.stop()
720
-
721
- # ─────────────────────────────────────────────────────────────────────────────
722
- # RESULTS TABLE + DOWNLOADS
723
- # ─────────────────────────────────────────────────────────────────────────────
724
- st.subheader("πŸ“‹ Results")
725
- display_cols = [c for c in st.session_state.df.columns if not c.startswith("_")]
726
- st.dataframe(st.session_state.df[display_cols], use_container_width=True, height=320)
727
-
728
- c1, c2 = st.columns(2)
729
- with c1:
730
- st.download_button("⬇️ Download metrics.csv",
731
- st.session_state.df[display_cols].to_csv(index=False).encode(),
732
- file_name="metrics.csv", mime="text/csv")
733
- with c2:
734
- st.download_button("⬇️ Download results.zip",
735
- st.session_state.zip_bytes,
736
- file_name="results.zip", mime="application/zip")
737
-
738
- st.divider()
739
-
740
- # ─────────────────────────────────────────────────────────────────────────────
741
- # PER-IMAGE PREVIEW + ANIMATED METRICS
742
- # ─────────────────────────────────────────────────────────────────────────────
743
- st.subheader("πŸ–ΌοΈ Image preview & live metrics")
744
- names = list(st.session_state.artifacts.keys())
745
- pick = st.selectbox("Select image", names)
746
-
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")
780
- bio = st.session_state.bio_metrics.get(pick, {})
781
- per_areas = bio.get("_per_myotube_areas", [])
782
-
783
- r1c1, r1c2, r1c3 = st.columns(3)
784
- r2c1, r2c2, r2c3 = st.columns(3)
785
- r3c1, r3c2, r3c3 = st.columns(3)
786
-
787
- placeholders = {
788
- "total_nuclei" : r1c1.empty(),
789
- "myotube_count" : r1c2.empty(),
790
- "myHC_positive_nuclei" : r1c3.empty(),
791
- "myHC_positive_percentage": r2c1.empty(),
792
- "fusion_index" : r2c2.empty(),
793
- "avg_nuclei_per_myotube" : r2c3.empty(),
794
- "total_area_um2" : r3c1.empty(),
795
- "mean_area_um2" : r3c2.empty(),
796
- "max_area_um2" : r3c3.empty(),
797
- }
798
-
799
- specs = [
800
- ("total_nuclei", "Total nuclei", "#4fc3f7", False),
801
- ("myotube_count", "Myotubes", "#ff8a65", False),
802
- ("myHC_positive_nuclei", "MyHC⁺ nuclei", "#a5d6a7", False),
803
- ("myHC_positive_percentage", "MyHC⁺ %", "#ce93d8", True),
804
- ("fusion_index", "Fusion index %", "#80cbc4", True),
805
- ("avg_nuclei_per_myotube", "Avg nuc/myotube", "#80deea", True),
806
- ("total_area_um2", f"Total area (Β΅mΒ²)", "#fff176", True),
807
- ("mean_area_um2", f"Mean area (Β΅mΒ²)", "#ffcc80", True),
808
- ("max_area_um2", f"Max area (Β΅mΒ²)", "#ef9a9a", True),
809
- ]
810
-
811
- for key, label, color, is_float in specs:
812
- val = bio.get(key, 0)
813
- animated_metric(placeholders[key], label,
814
- float(val) if is_float else int(val),
815
- color=color)
816
-
817
- if per_areas:
818
- st.markdown("#### πŸ“ Per-myotube area")
819
- area_df = pd.DataFrame({
820
- "Myotube" : [f"M{i+1}" for i in range(len(per_areas))],
821
- f"Area (Β΅mΒ²)" : per_areas,
822
- }).set_index("Myotube")
823
- st.bar_chart(area_df, height=220)
824
-
825
- st.divider()
826
-
827
- # ─────────────────────────────────────────────────────────────────────────────
828
- # ACTIVE LEARNING β€” CORRECTION UPLOAD
829
- # ─────────────────────────────────────────────────────────────────────────────
830
- if enable_al:
831
- st.subheader("🧠 Submit corrected labels (Active Learning)")
832
- st.caption(
833
- "Upload corrected binary masks for any image. "
834
- "Corrections are saved to corrections/ and picked up "
835
- "automatically by self_train.py at the next trigger check."
836
- )
837
-
838
- al_pick = st.selectbox("Correct masks for image", names, key="al_pick")
839
- acol1, acol2 = st.columns(2)
840
- with acol1:
841
- corr_nuc = st.file_uploader("Corrected NUCLEI mask (PNG/TIF, binary 0/255)",
842
- type=["png", "tif", "tiff"], key="nuc_corr")
843
- with acol2:
844
- corr_myo = st.file_uploader("Corrected MYOTUBE mask (PNG/TIF, binary 0/255)",
845
- type=["png", "tif", "tiff"], key="myo_corr")
846
-
847
- if st.button("βœ… Submit corrections", type="primary"):
848
- if corr_nuc is None or corr_myo is None:
849
- st.error("Please upload BOTH a nuclei mask and a myotube mask.")
850
- else:
851
- orig_bytes = st.session_state.artifacts[al_pick]["original"]
852
- orig_rgb = np.array(Image.open(io.BytesIO(orig_bytes)).convert("RGB"))
853
- nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
854
- myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
855
- add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
856
- reason="user_correction",
857
- metadata={"source_image": al_pick,
858
- "timestamp": datetime.now().isoformat()})
859
- st.success(
860
- f"βœ… Corrections for **{al_pick}** saved to `corrections/`. "
861
- "The model will retrain at the next scheduled cycle."
862
- )
863
-
864
- st.divider()
865
-
866
- # ─────────────────────────────────────────────────────────────────────────────
867
- # RETRAINING QUEUE STATUS
868
- # ─────────────────────────────────────────────────────────────────────────────
869
- with st.expander("πŸ”§ Self-training queue status"):
870
- _ensure_dirs()
871
- q_items = list(QUEUE_DIR.glob("*.json"))
872
- c_items = list(CORRECTIONS_DIR.glob("*/meta.json"))
873
-
874
- sq1, sq2 = st.columns(2)
875
- sq1.metric("Images in retraining queue", len(q_items))
876
- sq2.metric("Corrected label pairs", len(c_items))
877
-
878
- if q_items:
879
- reasons = {}
880
- for p in q_items:
881
- try:
882
- r = json.loads(p.read_text()).get("reason", "unknown")
883
- reasons[r] = reasons.get(r, 0) + 1
884
- except Exception:
885
- pass
886
- st.write("Queue breakdown:", reasons)
887
-
888
- manifest = Path("manifest.json")
889
- if manifest.exists():
890
- try:
891
- history = json.loads(manifest.read_text())
892
- if history:
893
- st.markdown("**Last 5 retraining runs:**")
894
- hist_df = pd.DataFrame(history[-5:])
895
- st.dataframe(hist_df, use_container_width=True)
896
- except Exception:
897
- pass
898
-
899
- if st.button("πŸ”„ Trigger retraining now"):
900
- import subprocess
901
- subprocess.Popen(["python", "self_train.py", "--manual"])
902
- st.info("Retraining started in the background. Check terminal / logs for progress.")