Edited streamlit_app.py
Browse files- src/streamlit_app.py +130 -73
src/streamlit_app.py
CHANGED
|
@@ -1107,20 +1107,20 @@ def build_mask_batch_zip(selected_names, artifacts: dict) -> bytes:
|
|
| 1107 |
return buf.getvalue()
|
| 1108 |
|
| 1109 |
|
| 1110 |
-
def _zip_png_mask(zf: zipfile.ZipFile, member: str) -> np.ndarray:
|
| 1111 |
-
with zf.open(member) as f:
|
| 1112 |
-
return (np.array(Image.open(f).convert("L")) > 0).astype(np.uint8)
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
def _pick_mask_member(files: dict, base_name: str):
|
| 1116 |
-
for ext in (".png", ".tif", ".tiff"):
|
| 1117 |
-
member = files.get(f"{base_name}{ext}")
|
| 1118 |
-
if member:
|
| 1119 |
-
return member
|
| 1120 |
-
return None
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
def import_correction_zip(zip_file, artifacts: dict) -> tuple[int, list[str]]:
|
| 1124 |
submitted = 0
|
| 1125 |
errors = []
|
| 1126 |
artifact_by_safe = {safe_zip_stem(name): name for name in artifacts}
|
|
@@ -1137,14 +1137,14 @@ def import_correction_zip(zip_file, artifacts: dict) -> tuple[int, list[str]]:
|
|
| 1137 |
|
| 1138 |
# Preferred format: folders produced by build_mask_batch_zip.
|
| 1139 |
folders = sorted({str(Path(m).parent).replace("\\", "/") for m in members if "/" in m})
|
| 1140 |
-
for folder in folders:
|
| 1141 |
-
if folder in ("", "."):
|
| 1142 |
-
continue
|
| 1143 |
-
files = {Path(m).name.lower(): m for m in members if str(Path(m).parent).replace("\\", "/") == folder}
|
| 1144 |
-
nuc_member = _pick_mask_member(files, "nuclei_mask")
|
| 1145 |
-
myo_member = _pick_mask_member(files, "myotube_mask")
|
| 1146 |
-
if not (nuc_member and myo_member):
|
| 1147 |
-
continue
|
| 1148 |
source = artifact_by_safe.get(Path(folder).name)
|
| 1149 |
meta_member = files.get("meta.json")
|
| 1150 |
if meta_member:
|
|
@@ -1154,12 +1154,12 @@ def import_correction_zip(zip_file, artifacts: dict) -> tuple[int, list[str]]:
|
|
| 1154 |
source = meta.get("source_image", source)
|
| 1155 |
except Exception:
|
| 1156 |
pass
|
| 1157 |
-
if source not in artifacts:
|
| 1158 |
-
errors.append(f"Skipped {folder}: source image is not in current analysis.")
|
| 1159 |
-
continue
|
| 1160 |
-
try:
|
| 1161 |
-
nuc_arr = _zip_png_mask(zf, nuc_member)
|
| 1162 |
-
myo_arr = _zip_png_mask(zf, myo_member)
|
| 1163 |
orig_rgb = artifacts[source]["rgb_u8"]
|
| 1164 |
add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
|
| 1165 |
reason="user_correction_batch",
|
|
@@ -1212,6 +1212,79 @@ def import_correction_zip(zip_file, artifacts: dict) -> tuple[int, list[str]]:
|
|
| 1212 |
# Model (architecture identical to training script)
|
| 1213 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
class DoubleConv(nn.Module):
|
| 1216 |
def __init__(self, in_ch, out_ch):
|
| 1217 |
super().__init__()
|
|
@@ -1927,38 +2000,11 @@ if not private_mode and names:
|
|
| 1927 |
if enable_al and not private_mode:
|
| 1928 |
st.subheader("π§ Submit corrected labels (Active Learning)")
|
| 1929 |
st.caption(
|
| 1930 |
-
"Download
|
| 1931 |
"image editor (white = foreground, black = background), then upload "
|
| 1932 |
-
"
|
| 1933 |
-
)
|
| 1934 |
-
|
| 1935 |
-
st.download_button(
|
| 1936 |
-
"Download batch correction ZIP",
|
| 1937 |
-
data=build_mask_batch_zip(names, st.session_state.artifacts),
|
| 1938 |
-
file_name=f"myoseg_correction_batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip",
|
| 1939 |
-
mime="application/zip",
|
| 1940 |
-
key="download_correction_batch",
|
| 1941 |
-
help="Includes one folder per image with image.png, nuclei_mask.png, myotube_mask.png, and meta.json.",
|
| 1942 |
)
|
| 1943 |
|
| 1944 |
-
batch_zip = st.file_uploader(
|
| 1945 |
-
"Upload corrected batch ZIP",
|
| 1946 |
-
type=["zip"],
|
| 1947 |
-
key="batch_corr_zip",
|
| 1948 |
-
help="Re-upload the ZIP after editing each folder's nuclei_mask.png and myotube_mask.png.",
|
| 1949 |
-
)
|
| 1950 |
-
if st.button("Submit batch corrections", type="secondary"):
|
| 1951 |
-
if batch_zip is None:
|
| 1952 |
-
st.error("Please upload a corrected ZIP batch first.")
|
| 1953 |
-
else:
|
| 1954 |
-
n_submitted, batch_errors = import_correction_zip(batch_zip, st.session_state.artifacts)
|
| 1955 |
-
if n_submitted:
|
| 1956 |
-
st.success(f"Saved {n_submitted} corrected image pair(s) to corrections/.")
|
| 1957 |
-
if batch_errors:
|
| 1958 |
-
st.warning("Some files were skipped:\n" + "\n".join(f"- {e}" for e in batch_errors[:10]))
|
| 1959 |
-
if not n_submitted and not batch_errors:
|
| 1960 |
-
st.error("No correction pairs were found in the ZIP.")
|
| 1961 |
-
|
| 1962 |
al_pick = st.selectbox("Correct masks for image", names, key="al_pick")
|
| 1963 |
|
| 1964 |
# Download buttons for current masks β lets users edit and re-upload
|
|
@@ -2004,27 +2050,38 @@ if enable_al and not private_mode:
|
|
| 2004 |
|
| 2005 |
acol1, acol2 = st.columns(2)
|
| 2006 |
with acol1:
|
| 2007 |
-
corr_nuc = st.file_uploader(
|
| 2008 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2009 |
with acol2:
|
| 2010 |
-
corr_myo = st.file_uploader(
|
| 2011 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2012 |
|
| 2013 |
if st.button("β
Submit corrections", type="primary"):
|
| 2014 |
-
if corr_nuc
|
| 2015 |
-
st.error("Please upload BOTH
|
| 2016 |
else:
|
| 2017 |
-
|
| 2018 |
-
|
| 2019 |
-
myo_arr = (np.array(Image.open(corr_myo).convert("L")) > 0).astype(np.uint8)
|
| 2020 |
-
add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
|
| 2021 |
-
reason="user_correction",
|
| 2022 |
-
metadata={"source_image": al_pick,
|
| 2023 |
-
"timestamp": datetime.now().isoformat()})
|
| 2024 |
-
st.success(
|
| 2025 |
-
f"β
Corrections for **{al_pick}** saved to `corrections/`. "
|
| 2026 |
-
"The model will retrain at the next scheduled cycle."
|
| 2027 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2028 |
elif enable_al and private_mode:
|
| 2029 |
st.info(
|
| 2030 |
"π Active learning and training submissions are disabled in Private mode. "
|
|
|
|
| 1107 |
return buf.getvalue()
|
| 1108 |
|
| 1109 |
|
| 1110 |
+
def _zip_png_mask(zf: zipfile.ZipFile, member: str) -> np.ndarray:
|
| 1111 |
+
with zf.open(member) as f:
|
| 1112 |
+
return (np.array(Image.open(f).convert("L")) > 0).astype(np.uint8)
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
def _pick_mask_member(files: dict, base_name: str):
|
| 1116 |
+
for ext in (".png", ".tif", ".tiff"):
|
| 1117 |
+
member = files.get(f"{base_name}{ext}")
|
| 1118 |
+
if member:
|
| 1119 |
+
return member
|
| 1120 |
+
return None
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
+
def import_correction_zip(zip_file, artifacts: dict) -> tuple[int, list[str]]:
|
| 1124 |
submitted = 0
|
| 1125 |
errors = []
|
| 1126 |
artifact_by_safe = {safe_zip_stem(name): name for name in artifacts}
|
|
|
|
| 1137 |
|
| 1138 |
# Preferred format: folders produced by build_mask_batch_zip.
|
| 1139 |
folders = sorted({str(Path(m).parent).replace("\\", "/") for m in members if "/" in m})
|
| 1140 |
+
for folder in folders:
|
| 1141 |
+
if folder in ("", "."):
|
| 1142 |
+
continue
|
| 1143 |
+
files = {Path(m).name.lower(): m for m in members if str(Path(m).parent).replace("\\", "/") == folder}
|
| 1144 |
+
nuc_member = _pick_mask_member(files, "nuclei_mask")
|
| 1145 |
+
myo_member = _pick_mask_member(files, "myotube_mask")
|
| 1146 |
+
if not (nuc_member and myo_member):
|
| 1147 |
+
continue
|
| 1148 |
source = artifact_by_safe.get(Path(folder).name)
|
| 1149 |
meta_member = files.get("meta.json")
|
| 1150 |
if meta_member:
|
|
|
|
| 1154 |
source = meta.get("source_image", source)
|
| 1155 |
except Exception:
|
| 1156 |
pass
|
| 1157 |
+
if source not in artifacts:
|
| 1158 |
+
errors.append(f"Skipped {folder}: source image is not in current analysis.")
|
| 1159 |
+
continue
|
| 1160 |
+
try:
|
| 1161 |
+
nuc_arr = _zip_png_mask(zf, nuc_member)
|
| 1162 |
+
myo_arr = _zip_png_mask(zf, myo_member)
|
| 1163 |
orig_rgb = artifacts[source]["rgb_u8"]
|
| 1164 |
add_to_queue(orig_rgb, nuc_mask=nuc_arr, myo_mask=myo_arr,
|
| 1165 |
reason="user_correction_batch",
|
|
|
|
| 1212 |
# Model (architecture identical to training script)
|
| 1213 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1214 |
|
| 1215 |
+
|
| 1216 |
+
def correction_stem(upload_name: str) -> str:
|
| 1217 |
+
stem = Path(upload_name).stem
|
| 1218 |
+
lower = stem.lower()
|
| 1219 |
+
for suffix in (
|
| 1220 |
+
"_nuclei_mask", "-nuclei-mask", "_nuclei", "-nuclei", "_nuc", "-nuc",
|
| 1221 |
+
"_myotube_mask", "-myotube-mask", "_myotubes_mask", "-myotubes-mask",
|
| 1222 |
+
"_myotube", "-myotube", "_myotubes", "-myotubes", "_myo", "-myo",
|
| 1223 |
+
):
|
| 1224 |
+
if lower.endswith(suffix):
|
| 1225 |
+
return stem[: -len(suffix)].strip()
|
| 1226 |
+
return stem.strip()
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
def uploaded_mask_to_array(uploaded_file) -> np.ndarray:
|
| 1230 |
+
return (np.array(Image.open(uploaded_file).convert("L")) > 0).astype(np.uint8)
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
+
def submit_uploaded_correction_pairs(nuc_files, myo_files, artifacts: dict, selected_name: str):
|
| 1234 |
+
nuc_files = nuc_files or []
|
| 1235 |
+
myo_files = myo_files or []
|
| 1236 |
+
if len(nuc_files) != len(myo_files):
|
| 1237 |
+
return 0, [f"Upload the same number of nuclei and myotube masks ({len(nuc_files)} nuclei, {len(myo_files)} myotube)."]
|
| 1238 |
+
|
| 1239 |
+
by_stem = {Path(name).stem: name for name in artifacts}
|
| 1240 |
+
by_safe = {safe_zip_stem(name): name for name in artifacts}
|
| 1241 |
+
nuc_by_stem = {correction_stem(f.name): f for f in nuc_files}
|
| 1242 |
+
myo_by_stem = {correction_stem(f.name): f for f in myo_files}
|
| 1243 |
+
common_stems = sorted(set(nuc_by_stem) & set(myo_by_stem))
|
| 1244 |
+
|
| 1245 |
+
if len(nuc_files) == len(myo_files) == 1 and not common_stems:
|
| 1246 |
+
common_stems = [selected_name]
|
| 1247 |
+
nuc_by_stem[selected_name] = nuc_files[0]
|
| 1248 |
+
myo_by_stem[selected_name] = myo_files[0]
|
| 1249 |
+
|
| 1250 |
+
submitted = 0
|
| 1251 |
+
errors = []
|
| 1252 |
+
missing_nuc = sorted(set(myo_by_stem) - set(nuc_by_stem))
|
| 1253 |
+
missing_myo = sorted(set(nuc_by_stem) - set(myo_by_stem))
|
| 1254 |
+
for stem in missing_nuc:
|
| 1255 |
+
errors.append(f"Skipped {stem}: no matching nuclei mask.")
|
| 1256 |
+
for stem in missing_myo:
|
| 1257 |
+
errors.append(f"Skipped {stem}: no matching myotube mask.")
|
| 1258 |
+
|
| 1259 |
+
for stem in common_stems:
|
| 1260 |
+
source = by_stem.get(stem) or by_safe.get(stem)
|
| 1261 |
+
if source is None and len(common_stems) == 1:
|
| 1262 |
+
source = selected_name
|
| 1263 |
+
if source not in artifacts:
|
| 1264 |
+
errors.append(f"Skipped {stem}: no matching analyzed image is loaded.")
|
| 1265 |
+
continue
|
| 1266 |
+
|
| 1267 |
+
try:
|
| 1268 |
+
nuc_arr = uploaded_mask_to_array(nuc_by_stem[stem])
|
| 1269 |
+
myo_arr = uploaded_mask_to_array(myo_by_stem[stem])
|
| 1270 |
+
add_to_queue(
|
| 1271 |
+
artifacts[source]["rgb_u8"],
|
| 1272 |
+
nuc_mask=nuc_arr,
|
| 1273 |
+
myo_mask=myo_arr,
|
| 1274 |
+
reason="user_correction_batch" if len(common_stems) > 1 else "user_correction",
|
| 1275 |
+
metadata={
|
| 1276 |
+
"source_image": source,
|
| 1277 |
+
"nuclei_file": nuc_by_stem[stem].name,
|
| 1278 |
+
"myotube_file": myo_by_stem[stem].name,
|
| 1279 |
+
"timestamp": datetime.now().isoformat(),
|
| 1280 |
+
},
|
| 1281 |
+
)
|
| 1282 |
+
submitted += 1
|
| 1283 |
+
except Exception as exc:
|
| 1284 |
+
errors.append(f"Skipped {stem}: {exc}")
|
| 1285 |
+
|
| 1286 |
+
return submitted, errors
|
| 1287 |
+
|
| 1288 |
class DoubleConv(nn.Module):
|
| 1289 |
def __init__(self, in_ch, out_ch):
|
| 1290 |
super().__init__()
|
|
|
|
| 2000 |
if enable_al and not private_mode:
|
| 2001 |
st.subheader("π§ Submit corrected labels (Active Learning)")
|
| 2002 |
st.caption(
|
| 2003 |
+
"Download predicted masks, correct them in ImageJ/FIJI, napari, or any "
|
| 2004 |
"image editor (white = foreground, black = background), then upload "
|
| 2005 |
+
"one corrected pair or many corrected pairs below."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2006 |
)
|
| 2007 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2008 |
al_pick = st.selectbox("Correct masks for image", names, key="al_pick")
|
| 2009 |
|
| 2010 |
# Download buttons for current masks β lets users edit and re-upload
|
|
|
|
| 2050 |
|
| 2051 |
acol1, acol2 = st.columns(2)
|
| 2052 |
with acol1:
|
| 2053 |
+
corr_nuc = st.file_uploader(
|
| 2054 |
+
"Upload corrected NUCLEI mask(s)",
|
| 2055 |
+
type=["png", "tif", "tiff"],
|
| 2056 |
+
accept_multiple_files=True,
|
| 2057 |
+
key="nuc_corr",
|
| 2058 |
+
help="Upload one nuclei mask for the selected image, or select many nuclei masks for batch correction.",
|
| 2059 |
+
)
|
| 2060 |
with acol2:
|
| 2061 |
+
corr_myo = st.file_uploader(
|
| 2062 |
+
"Upload corrected MYOTUBE mask(s)",
|
| 2063 |
+
type=["png", "tif", "tiff"],
|
| 2064 |
+
accept_multiple_files=True,
|
| 2065 |
+
key="myo_corr",
|
| 2066 |
+
help="Upload the matching myotube mask(s). Batch files are paired by filename stem.",
|
| 2067 |
+
)
|
| 2068 |
|
| 2069 |
if st.button("β
Submit corrections", type="primary"):
|
| 2070 |
+
if not corr_nuc or not corr_myo:
|
| 2071 |
+
st.error("Please upload BOTH nuclei and myotube mask files.")
|
| 2072 |
else:
|
| 2073 |
+
n_submitted, correction_errors = submit_uploaded_correction_pairs(
|
| 2074 |
+
corr_nuc, corr_myo, st.session_state.artifacts, al_pick
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2075 |
)
|
| 2076 |
+
if n_submitted == 1:
|
| 2077 |
+
st.success("Saved 1 corrected image pair to corrections/.")
|
| 2078 |
+
elif n_submitted > 1:
|
| 2079 |
+
st.success(f"Saved {n_submitted} corrected image pairs to corrections/.")
|
| 2080 |
+
if correction_errors:
|
| 2081 |
+
st.warning("Some files were skipped:\n" + "\n".join(f"- {e}" for e in correction_errors[:12]))
|
| 2082 |
+
if not n_submitted and not correction_errors:
|
| 2083 |
+
st.error("No matching correction pairs were found.")
|
| 2084 |
+
|
| 2085 |
elif enable_al and private_mode:
|
| 2086 |
st.info(
|
| 2087 |
"π Active learning and training submissions are disabled in Private mode. "
|