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