Update Streamlit app to v8 and self_train to v2
Browse files- src/streamlit_app.py +162 -56
src/streamlit_app.py
CHANGED
|
@@ -4,20 +4,25 @@ MyoSight β Myotube & Nuclei Analyser
|
|
| 4 |
========================================
|
| 5 |
Drop-in replacement for streamlit_app.py on Hugging Face Spaces.
|
| 6 |
|
| 7 |
-
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
import io
|
|
@@ -46,7 +51,7 @@ from huggingface_hub import hf_hub_download
|
|
| 46 |
import scipy.ndimage as ndi
|
| 47 |
from skimage.morphology import remove_small_objects, disk, closing, opening, binary_dilation, binary_erosion
|
| 48 |
from skimage import measure
|
| 49 |
-
from skimage.segmentation import watershed
|
| 50 |
from skimage.feature import peak_local_max
|
| 51 |
|
| 52 |
|
|
@@ -103,39 +108,68 @@ def hex_to_rgb(h: str):
|
|
| 103 |
|
| 104 |
def postprocess_masks(nuc_mask, myo_mask,
|
| 105 |
min_nuc_area=20, min_myo_area=500,
|
| 106 |
-
nuc_close_radius=2,
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
"""
|
| 109 |
Clean up raw predicted masks.
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
"""
|
| 122 |
-
# Nuclei
|
| 123 |
nuc_bin = nuc_mask.astype(bool)
|
| 124 |
if int(nuc_close_radius) > 0:
|
| 125 |
nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
|
| 126 |
nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
|
| 127 |
|
| 128 |
-
# Myotubes β
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
if int(myo_erode_radius) > 0:
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
return nuc_clean, myo_clean
|
| 137 |
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def label_cc(mask: np.ndarray) -> np.ndarray:
|
| 140 |
lab, _ = ndi.label(mask.astype(np.uint8))
|
| 141 |
return lab
|
|
@@ -521,6 +555,45 @@ def make_coloured_overlay(rgb_u8: np.ndarray,
|
|
| 521 |
return np.clip(base, 0, 255).astype(np.uint8)
|
| 522 |
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
def collect_label_positions(nuc_lab: np.ndarray,
|
| 525 |
myo_lab: np.ndarray,
|
| 526 |
img_w: int, img_h: int) -> dict:
|
|
@@ -1066,8 +1139,8 @@ with st.sidebar:
|
|
| 1066 |
options=[256, 384, 512, 640, 768, 1024], value=512)
|
| 1067 |
|
| 1068 |
st.header("Thresholds")
|
| 1069 |
-
thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.
|
| 1070 |
-
thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.
|
| 1071 |
|
| 1072 |
st.header("Fusion Index method")
|
| 1073 |
fi_method = st.radio(
|
|
@@ -1101,41 +1174,47 @@ with st.sidebar:
|
|
| 1101 |
min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
|
| 1102 |
min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
|
| 1103 |
nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
|
| 1104 |
-
|
|
|
|
|
|
|
| 1105 |
|
| 1106 |
-
st.header("Myotube
|
| 1107 |
st.caption(
|
| 1108 |
-
"
|
| 1109 |
-
"
|
| 1110 |
-
"These three controls correct that."
|
| 1111 |
)
|
| 1112 |
myo_erode_radius = st.number_input(
|
| 1113 |
"Myotube erode radius (px)", 0, 15, 2, 1,
|
| 1114 |
help=(
|
| 1115 |
-
"
|
| 1116 |
-
"
|
| 1117 |
-
"3
|
| 1118 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1119 |
)
|
| 1120 |
)
|
| 1121 |
myo_max_area_px = st.number_input(
|
| 1122 |
-
"Max myotube area before split (pxΒ²)", 0, 500000,
|
| 1123 |
help=(
|
| 1124 |
-
"Any connected myotube region larger than this is
|
| 1125 |
-
"
|
| 1126 |
-
"
|
| 1127 |
-
"With ~5 nuc/myo and ~300 px nuclei, try 8000-15000 px2. "
|
| 1128 |
-
"Set to 0 to disable automatic splitting."
|
| 1129 |
)
|
| 1130 |
)
|
| 1131 |
myo_split_min_seeds = st.number_input(
|
| 1132 |
-
"Min nuclei seeds to split", 2, 20,
|
| 1133 |
help=(
|
| 1134 |
-
"
|
| 1135 |
-
"
|
| 1136 |
-
"
|
| 1137 |
-
"Increase if single large myotubes are being incorrectly split. "
|
| 1138 |
-
"Decrease if merged regions with few nuclei are being missed."
|
| 1139 |
)
|
| 1140 |
)
|
| 1141 |
|
|
@@ -1244,8 +1323,9 @@ if run:
|
|
| 1244 |
min_nuc_area=int(min_nuc_area),
|
| 1245 |
min_myo_area=int(min_myo_area),
|
| 1246 |
nuc_close_radius=int(nuc_close_radius),
|
| 1247 |
-
|
| 1248 |
myo_erode_radius=int(myo_erode_radius),
|
|
|
|
| 1249 |
)
|
| 1250 |
|
| 1251 |
# Flat overlay for ZIP (no labels β just colour regions)
|
|
@@ -1297,6 +1377,9 @@ if run:
|
|
| 1297 |
"rgb_u8" : rgb_u8,
|
| 1298 |
"nuc_lab" : nuc_lab,
|
| 1299 |
"myo_lab" : myo_lab,
|
|
|
|
|
|
|
|
|
|
| 1300 |
# static mask PNGs
|
| 1301 |
"nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
|
| 1302 |
"myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
|
|
@@ -1310,10 +1393,14 @@ if run:
|
|
| 1310 |
}
|
| 1311 |
|
| 1312 |
# ZIP built with current colour settings at run time
|
|
|
|
|
|
|
|
|
|
| 1313 |
zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
|
| 1314 |
zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
|
| 1315 |
zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
|
| 1316 |
zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
|
|
|
|
| 1317 |
zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
|
| 1318 |
zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
|
| 1319 |
zf.writestr(f"{name}/nuclei_raw.png", artifacts[name]["nuc_raw_bytes"])
|
|
@@ -1385,10 +1472,14 @@ with c3:
|
|
| 1385 |
(_nl > 0).astype(np.uint8),
|
| 1386 |
(_ml > 0).astype(np.uint8),
|
| 1387 |
nuc_rgb, myo_rgb, float(alpha))
|
|
|
|
|
|
|
|
|
|
| 1388 |
zf.writestr(f"{img_name}/overlay_combined.png", png_bytes(simple))
|
| 1389 |
zf.writestr(f"{img_name}/overlay_instance.png", png_bytes(ov_comb))
|
| 1390 |
zf.writestr(f"{img_name}/overlay_nuclei.png", png_bytes(ov_nuc))
|
| 1391 |
zf.writestr(f"{img_name}/overlay_myotubes.png", png_bytes(ov_myo))
|
|
|
|
| 1392 |
zf.writestr(f"{img_name}/nuclei_pp.png", art["nuc_pp"])
|
| 1393 |
zf.writestr(f"{img_name}/myotube_pp.png", art["myo_pp"])
|
| 1394 |
zf.writestr(f"{img_name}/nuclei_raw.png", art["nuc_raw_bytes"])
|
|
@@ -1412,6 +1503,7 @@ col_img, col_metrics = st.columns([3, 2], gap="large")
|
|
| 1412 |
with col_img:
|
| 1413 |
tabs = st.tabs([
|
| 1414 |
"π΅ Combined",
|
|
|
|
| 1415 |
"π£ Nuclei only",
|
| 1416 |
"π Myotubes only",
|
| 1417 |
"π· Original",
|
|
@@ -1455,6 +1547,21 @@ with col_img:
|
|
| 1455 |
st.components.v1.html(html_combined, height=680, scrolling=False)
|
| 1456 |
|
| 1457 |
with tabs[1]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1458 |
nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
|
| 1459 |
html_nuc = make_svg_viewer(
|
| 1460 |
nuc_only_b64, iw, ih, nuc_only_lpos,
|
|
@@ -1462,7 +1569,7 @@ with col_img:
|
|
| 1462 |
)
|
| 1463 |
st.components.v1.html(html_nuc, height=680, scrolling=False)
|
| 1464 |
|
| 1465 |
-
with tabs[
|
| 1466 |
myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
|
| 1467 |
html_myo = make_svg_viewer(
|
| 1468 |
myo_only_b64, iw, ih, myo_only_lpos,
|
|
@@ -1470,11 +1577,11 @@ with col_img:
|
|
| 1470 |
)
|
| 1471 |
st.components.v1.html(html_myo, height=680, scrolling=False)
|
| 1472 |
|
| 1473 |
-
with tabs[3]:
|
| 1474 |
-
st.image(art["rgb_u8"], use_container_width=True)
|
| 1475 |
with tabs[4]:
|
| 1476 |
-
st.image(art["
|
| 1477 |
with tabs[5]:
|
|
|
|
|
|
|
| 1478 |
st.image(art["myo_pp"], use_container_width=True)
|
| 1479 |
|
| 1480 |
with col_metrics:
|
|
@@ -1550,8 +1657,7 @@ if enable_al:
|
|
| 1550 |
if corr_nuc is None or corr_myo is None:
|
| 1551 |
st.error("Please upload BOTH a nuclei mask and a myotube mask.")
|
| 1552 |
else:
|
| 1553 |
-
|
| 1554 |
-
orig_rgb = np.array(Image.open(io.BytesIO(orig_bytes)).convert("RGB"))
|
| 1555 |
nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
|
| 1556 |
myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
|
| 1557 |
add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
|
|
|
|
| 4 |
========================================
|
| 5 |
Drop-in replacement for streamlit_app.py on Hugging Face Spaces.
|
| 6 |
|
| 7 |
+
Features:
|
| 8 |
β¦ Animated count-up metrics (9 counters)
|
| 9 |
β¦ Instance overlay β nucleus IDs (1,2,3β¦) + myotube IDs (M1,M2β¦)
|
| 10 |
+
β¦ Contour outline overlay β see exactly what each detection covers
|
| 11 |
β¦ Watershed nuclei splitting for accurate counts
|
| 12 |
β¦ Myotube surface area (total, mean, max Β΅mΒ²) + per-tube bar chart
|
| 13 |
β¦ Active learning β upload corrected masks β saved to corrections/
|
| 14 |
β¦ Low-confidence auto-flagging β image queued for retraining
|
| 15 |
β¦ Retraining queue status panel
|
| 16 |
β¦ All original sidebar controls preserved
|
| 17 |
+
|
| 18 |
+
v8 changes (validated against 57-well manual count dataset):
|
| 19 |
+
β¦ REMOVED myotube closing β was merging adjacent myotubes into single blobs
|
| 20 |
+
(caused 86% of images to undercount; r=0.245 β see validation report).
|
| 21 |
+
β¦ Unified postprocessing: opening + erode/dilate (matches training script).
|
| 22 |
+
β¦ Added aspect-ratio shape filter to reject round debris false positives.
|
| 23 |
+
β¦ Added contour outline tab per collaborator request.
|
| 24 |
+
β¦ Fixed active learning correction upload bug (art["original"] β art["rgb_u8"]).
|
| 25 |
+
β¦ Unified threshold defaults across all scripts (thr_myo=0.40, thr_nuc=0.45).
|
| 26 |
"""
|
| 27 |
|
| 28 |
import io
|
|
|
|
| 51 |
import scipy.ndimage as ndi
|
| 52 |
from skimage.morphology import remove_small_objects, disk, closing, opening, binary_dilation, binary_erosion
|
| 53 |
from skimage import measure
|
| 54 |
+
from skimage.segmentation import watershed, find_boundaries
|
| 55 |
from skimage.feature import peak_local_max
|
| 56 |
|
| 57 |
|
|
|
|
| 108 |
|
| 109 |
def postprocess_masks(nuc_mask, myo_mask,
|
| 110 |
min_nuc_area=20, min_myo_area=500,
|
| 111 |
+
nuc_close_radius=2,
|
| 112 |
+
myo_open_radius=2,
|
| 113 |
+
myo_erode_radius=2,
|
| 114 |
+
min_myo_aspect_ratio=0.0):
|
| 115 |
"""
|
| 116 |
Clean up raw predicted masks.
|
| 117 |
|
| 118 |
+
v8 β unified postprocessing (matches training script):
|
| 119 |
+
Nuclei: optional closing to fill gaps, then remove small objects.
|
| 120 |
+
Myotubes: opening (noise removal) β erode+dilate (bridge breaking) β
|
| 121 |
+
size filter β optional aspect-ratio shape filter.
|
| 122 |
+
|
| 123 |
+
NO closing for myotubes β closing merges adjacent myotubes into single
|
| 124 |
+
connected components, causing severe undercounting in dense cultures.
|
| 125 |
+
Validation showed r=0.245 with closing vs manual counts.
|
| 126 |
+
|
| 127 |
+
myo_open_radius β disk radius for morphological opening. Removes small
|
| 128 |
+
noise/debris without merging separate objects.
|
| 129 |
+
myo_erode_radius β disk radius for erode+dilate bridge-breaking. Separates
|
| 130 |
+
touching myotubes that share thin pixel bridges.
|
| 131 |
+
Start at 2 px; increase for dense cultures. Set 0 to disable.
|
| 132 |
+
min_myo_aspect_ratio β minimum major/minor axis ratio. Myotubes are elongated
|
| 133 |
+
(aspect > 2); round debris blobs (aspect ~1) are rejected.
|
| 134 |
+
Set 0 to disable. Recommended: 1.5β2.0 for sparse cultures.
|
| 135 |
"""
|
| 136 |
+
# Nuclei β closing fills small gaps, then size filter
|
| 137 |
nuc_bin = nuc_mask.astype(bool)
|
| 138 |
if int(nuc_close_radius) > 0:
|
| 139 |
nuc_bin = closing(nuc_bin, disk(int(nuc_close_radius)))
|
| 140 |
nuc_clean = remove_small_objects(nuc_bin, min_size=int(min_nuc_area)).astype(np.uint8)
|
| 141 |
|
| 142 |
+
# Myotubes β opening + erode/dilate + size filter + shape filter
|
| 143 |
+
myo_bin = myo_mask.astype(bool)
|
| 144 |
+
if int(myo_open_radius) > 0:
|
| 145 |
+
myo_bin = opening(myo_bin, disk(int(myo_open_radius)))
|
| 146 |
if int(myo_erode_radius) > 0:
|
| 147 |
+
se = disk(int(myo_erode_radius))
|
| 148 |
+
myo_bin = binary_erosion(myo_bin, se)
|
| 149 |
+
myo_bin = binary_dilation(myo_bin, se) # re-dilate to restore size
|
| 150 |
+
myo_bin = remove_small_objects(myo_bin, min_size=int(min_myo_area))
|
| 151 |
+
if float(min_myo_aspect_ratio) > 0:
|
| 152 |
+
myo_bin = _filter_by_aspect_ratio(myo_bin, float(min_myo_aspect_ratio))
|
| 153 |
+
myo_clean = myo_bin.astype(np.uint8)
|
| 154 |
|
| 155 |
return nuc_clean, myo_clean
|
| 156 |
|
| 157 |
|
| 158 |
+
def _filter_by_aspect_ratio(mask_bin: np.ndarray, min_aspect: float) -> np.ndarray:
|
| 159 |
+
"""Keep only regions with major/minor axis ratio >= min_aspect."""
|
| 160 |
+
lab, _ = ndi.label(mask_bin.astype(np.uint8))
|
| 161 |
+
keep = np.zeros_like(mask_bin, dtype=bool)
|
| 162 |
+
for prop in measure.regionprops(lab):
|
| 163 |
+
if prop.minor_axis_length > 0:
|
| 164 |
+
aspect = prop.major_axis_length / prop.minor_axis_length
|
| 165 |
+
if aspect >= min_aspect:
|
| 166 |
+
keep[lab == prop.label] = True
|
| 167 |
+
else:
|
| 168 |
+
# Degenerate (line-like) β keep it (very elongated)
|
| 169 |
+
keep[lab == prop.label] = True
|
| 170 |
+
return keep
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def label_cc(mask: np.ndarray) -> np.ndarray:
|
| 174 |
lab, _ = ndi.label(mask.astype(np.uint8))
|
| 175 |
return lab
|
|
|
|
| 555 |
return np.clip(base, 0, 255).astype(np.uint8)
|
| 556 |
|
| 557 |
|
| 558 |
+
def make_outline_overlay(rgb_u8: np.ndarray,
|
| 559 |
+
nuc_lab: np.ndarray,
|
| 560 |
+
myo_lab: np.ndarray,
|
| 561 |
+
nuc_color: tuple = (0, 255, 255),
|
| 562 |
+
myo_color: tuple = (0, 255, 0),
|
| 563 |
+
line_width: int = 2) -> np.ndarray:
|
| 564 |
+
"""
|
| 565 |
+
Draw contour outlines around each detected instance on the original image.
|
| 566 |
+
Shows exactly what the model considers each myotube/nucleus boundary.
|
| 567 |
+
"""
|
| 568 |
+
orig_h, orig_w = rgb_u8.shape[:2]
|
| 569 |
+
|
| 570 |
+
def _resize_lab(lab, h, w):
|
| 571 |
+
return np.array(
|
| 572 |
+
Image.fromarray(lab.astype(np.int32)).resize((w, h), Image.NEAREST)
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
nuc_disp = _resize_lab(nuc_lab, orig_h, orig_w)
|
| 576 |
+
myo_disp = _resize_lab(myo_lab, orig_h, orig_w)
|
| 577 |
+
|
| 578 |
+
out = rgb_u8.copy()
|
| 579 |
+
|
| 580 |
+
# Myotube outlines
|
| 581 |
+
if myo_disp.max() > 0:
|
| 582 |
+
myo_bounds = find_boundaries(myo_disp, mode='outer')
|
| 583 |
+
if line_width > 1:
|
| 584 |
+
myo_bounds = binary_dilation(myo_bounds, footprint=disk(line_width - 1))
|
| 585 |
+
out[myo_bounds] = myo_color
|
| 586 |
+
|
| 587 |
+
# Nuclei outlines
|
| 588 |
+
if nuc_disp.max() > 0:
|
| 589 |
+
nuc_bounds = find_boundaries(nuc_disp, mode='outer')
|
| 590 |
+
if line_width > 1:
|
| 591 |
+
nuc_bounds = binary_dilation(nuc_bounds, footprint=disk(max(line_width - 2, 0)))
|
| 592 |
+
out[nuc_bounds] = nuc_color
|
| 593 |
+
|
| 594 |
+
return out
|
| 595 |
+
|
| 596 |
+
|
| 597 |
def collect_label_positions(nuc_lab: np.ndarray,
|
| 598 |
myo_lab: np.ndarray,
|
| 599 |
img_w: int, img_h: int) -> dict:
|
|
|
|
| 1139 |
options=[256, 384, 512, 640, 768, 1024], value=512)
|
| 1140 |
|
| 1141 |
st.header("Thresholds")
|
| 1142 |
+
thr_nuc = st.slider("Nuclei threshold", 0.05, 0.95, 0.45, 0.01)
|
| 1143 |
+
thr_myo = st.slider("Myotube threshold", 0.05, 0.95, 0.40, 0.01)
|
| 1144 |
|
| 1145 |
st.header("Fusion Index method")
|
| 1146 |
fi_method = st.radio(
|
|
|
|
| 1174 |
min_nuc_area = st.number_input("Min nucleus area (px)", 0, 10000, 20, 1)
|
| 1175 |
min_myo_area = st.number_input("Min myotube area (px)", 0, 200000, 500, 10)
|
| 1176 |
nuc_close_radius = st.number_input("Nuclei close radius", 0, 50, 2, 1)
|
| 1177 |
+
myo_open_radius = st.number_input("Myotube open radius", 0, 50, 2, 1,
|
| 1178 |
+
help="Opening removes small noise without merging separate myotubes. "
|
| 1179 |
+
"Replaces the old closing radius which was merging adjacent myotubes.")
|
| 1180 |
|
| 1181 |
+
st.header("Myotube separation")
|
| 1182 |
st.caption(
|
| 1183 |
+
"These controls break apart touching/bridged myotubes that would "
|
| 1184 |
+
"otherwise be counted as a single object."
|
|
|
|
| 1185 |
)
|
| 1186 |
myo_erode_radius = st.number_input(
|
| 1187 |
"Myotube erode radius (px)", 0, 15, 2, 1,
|
| 1188 |
help=(
|
| 1189 |
+
"Erode + re-dilate breaks thin pixel bridges between adjacent "
|
| 1190 |
+
"myotubes while preserving their overall size. "
|
| 1191 |
+
"Start at 2 px; increase to 3β4 px for very dense cultures. "
|
| 1192 |
+
"Set 0 to disable."
|
| 1193 |
+
)
|
| 1194 |
+
)
|
| 1195 |
+
min_myo_aspect_ratio = st.number_input(
|
| 1196 |
+
"Min myotube aspect ratio", 0.0, 10.0, 0.0, 0.1,
|
| 1197 |
+
help=(
|
| 1198 |
+
"Rejects round blobs (debris/artifacts) that are not real myotubes. "
|
| 1199 |
+
"Myotubes are elongated (aspect ratio > 3). Round objects have ~1. "
|
| 1200 |
+
"Set to 1.5β2.0 to filter false positives in sparse cultures. "
|
| 1201 |
+
"Set to 0 to disable (default)."
|
| 1202 |
)
|
| 1203 |
)
|
| 1204 |
myo_max_area_px = st.number_input(
|
| 1205 |
+
"Max myotube area before split (pxΒ²)", 0, 500000, 20000, 500,
|
| 1206 |
help=(
|
| 1207 |
+
"Any connected myotube region larger than this is split using "
|
| 1208 |
+
"nucleus-seeded watershed. Set to 0 to disable. "
|
| 1209 |
+
"Increase for cultures with legitimately large single myotubes."
|
|
|
|
|
|
|
| 1210 |
)
|
| 1211 |
)
|
| 1212 |
myo_split_min_seeds = st.number_input(
|
| 1213 |
+
"Min nuclei seeds to split", 2, 20, 2, 1,
|
| 1214 |
help=(
|
| 1215 |
+
"Minimum nucleus centroids required before splitting a large region. "
|
| 1216 |
+
"Set to 2 to split merged pairs; increase if single large myotubes "
|
| 1217 |
+
"are being incorrectly split."
|
|
|
|
|
|
|
| 1218 |
)
|
| 1219 |
)
|
| 1220 |
|
|
|
|
| 1323 |
min_nuc_area=int(min_nuc_area),
|
| 1324 |
min_myo_area=int(min_myo_area),
|
| 1325 |
nuc_close_radius=int(nuc_close_radius),
|
| 1326 |
+
myo_open_radius=int(myo_open_radius),
|
| 1327 |
myo_erode_radius=int(myo_erode_radius),
|
| 1328 |
+
min_myo_aspect_ratio=float(min_myo_aspect_ratio),
|
| 1329 |
)
|
| 1330 |
|
| 1331 |
# Flat overlay for ZIP (no labels β just colour regions)
|
|
|
|
| 1377 |
"rgb_u8" : rgb_u8,
|
| 1378 |
"nuc_lab" : nuc_lab,
|
| 1379 |
"myo_lab" : myo_lab,
|
| 1380 |
+
# postprocessed masks (for outline generation)
|
| 1381 |
+
"nuc_pp_arr" : nuc_pp,
|
| 1382 |
+
"myo_pp_arr" : myo_pp,
|
| 1383 |
# static mask PNGs
|
| 1384 |
"nuc_pp" : png_bytes((nuc_pp * 255).astype(np.uint8)),
|
| 1385 |
"myo_pp" : png_bytes((myo_pp * 255).astype(np.uint8)),
|
|
|
|
| 1393 |
}
|
| 1394 |
|
| 1395 |
# ZIP built with current colour settings at run time
|
| 1396 |
+
outline_ov = make_outline_overlay(rgb_u8, nuc_lab, myo_lab,
|
| 1397 |
+
nuc_color=nuc_rgb, myo_color=(0, 255, 0),
|
| 1398 |
+
line_width=2)
|
| 1399 |
zf.writestr(f"{name}/overlay_combined.png", png_bytes(simple_ov))
|
| 1400 |
zf.writestr(f"{name}/overlay_instance.png", png_bytes(inst_px))
|
| 1401 |
zf.writestr(f"{name}/overlay_nuclei.png", png_bytes(nuc_only_px))
|
| 1402 |
zf.writestr(f"{name}/overlay_myotubes.png", png_bytes(myo_only_px))
|
| 1403 |
+
zf.writestr(f"{name}/overlay_outlines.png", png_bytes(outline_ov))
|
| 1404 |
zf.writestr(f"{name}/nuclei_pp.png", artifacts[name]["nuc_pp"])
|
| 1405 |
zf.writestr(f"{name}/myotube_pp.png", artifacts[name]["myo_pp"])
|
| 1406 |
zf.writestr(f"{name}/nuclei_raw.png", artifacts[name]["nuc_raw_bytes"])
|
|
|
|
| 1472 |
(_nl > 0).astype(np.uint8),
|
| 1473 |
(_ml > 0).astype(np.uint8),
|
| 1474 |
nuc_rgb, myo_rgb, float(alpha))
|
| 1475 |
+
outline = make_outline_overlay(_r, _nl, _ml,
|
| 1476 |
+
nuc_color=nuc_rgb, myo_color=(0, 255, 0),
|
| 1477 |
+
line_width=2)
|
| 1478 |
zf.writestr(f"{img_name}/overlay_combined.png", png_bytes(simple))
|
| 1479 |
zf.writestr(f"{img_name}/overlay_instance.png", png_bytes(ov_comb))
|
| 1480 |
zf.writestr(f"{img_name}/overlay_nuclei.png", png_bytes(ov_nuc))
|
| 1481 |
zf.writestr(f"{img_name}/overlay_myotubes.png", png_bytes(ov_myo))
|
| 1482 |
+
zf.writestr(f"{img_name}/overlay_outlines.png", png_bytes(outline))
|
| 1483 |
zf.writestr(f"{img_name}/nuclei_pp.png", art["nuc_pp"])
|
| 1484 |
zf.writestr(f"{img_name}/myotube_pp.png", art["myo_pp"])
|
| 1485 |
zf.writestr(f"{img_name}/nuclei_raw.png", art["nuc_raw_bytes"])
|
|
|
|
| 1503 |
with col_img:
|
| 1504 |
tabs = st.tabs([
|
| 1505 |
"π΅ Combined",
|
| 1506 |
+
"π Outlines",
|
| 1507 |
"π£ Nuclei only",
|
| 1508 |
"π Myotubes only",
|
| 1509 |
"π· Original",
|
|
|
|
| 1547 |
st.components.v1.html(html_combined, height=680, scrolling=False)
|
| 1548 |
|
| 1549 |
with tabs[1]:
|
| 1550 |
+
# Outline overlay β shows contour boundaries around each detection
|
| 1551 |
+
outline_img = make_outline_overlay(
|
| 1552 |
+
_rgb, _nl, _ml,
|
| 1553 |
+
nuc_color=nuc_rgb, myo_color=(0, 255, 0),
|
| 1554 |
+
line_width=2,
|
| 1555 |
+
)
|
| 1556 |
+
outline_b64 = _b64png_disp(outline_img)
|
| 1557 |
+
outline_lpos = lpos # show both labels on outline view
|
| 1558 |
+
html_outline = make_svg_viewer(
|
| 1559 |
+
outline_b64, iw, ih, outline_lpos,
|
| 1560 |
+
show_nuclei=label_nuc, show_myotubes=label_myo,
|
| 1561 |
+
)
|
| 1562 |
+
st.components.v1.html(html_outline, height=680, scrolling=False)
|
| 1563 |
+
|
| 1564 |
+
with tabs[2]:
|
| 1565 |
nuc_only_lpos = {"nuclei": lpos["nuclei"], "myotubes": []}
|
| 1566 |
html_nuc = make_svg_viewer(
|
| 1567 |
nuc_only_b64, iw, ih, nuc_only_lpos,
|
|
|
|
| 1569 |
)
|
| 1570 |
st.components.v1.html(html_nuc, height=680, scrolling=False)
|
| 1571 |
|
| 1572 |
+
with tabs[3]:
|
| 1573 |
myo_only_lpos = {"nuclei": [], "myotubes": lpos["myotubes"]}
|
| 1574 |
html_myo = make_svg_viewer(
|
| 1575 |
myo_only_b64, iw, ih, myo_only_lpos,
|
|
|
|
| 1577 |
)
|
| 1578 |
st.components.v1.html(html_myo, height=680, scrolling=False)
|
| 1579 |
|
|
|
|
|
|
|
| 1580 |
with tabs[4]:
|
| 1581 |
+
st.image(art["rgb_u8"], use_container_width=True)
|
| 1582 |
with tabs[5]:
|
| 1583 |
+
st.image(art["nuc_pp"], use_container_width=True)
|
| 1584 |
+
with tabs[6]:
|
| 1585 |
st.image(art["myo_pp"], use_container_width=True)
|
| 1586 |
|
| 1587 |
with col_metrics:
|
|
|
|
| 1657 |
if corr_nuc is None or corr_myo is None:
|
| 1658 |
st.error("Please upload BOTH a nuclei mask and a myotube mask.")
|
| 1659 |
else:
|
| 1660 |
+
orig_rgb = st.session_state.artifacts[al_pick]["rgb_u8"]
|
|
|
|
| 1661 |
nuc_arr = (np.array(Image.open(corr_nuc).convert("L")) > 0).astype(np.uint8)
|
| 1662 |
myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
|
| 1663 |
add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
|