Upload app.py
Browse filesthree changes:
1. The minimum-size message now states what was used, not what's recommended:
Minimum size filter applied: 142 px β auto, the 25th percentile of THIS image. Recomputed per image, so it differs between photos. Set the slider to a fixed value for reproducible counts.
With a manual value it says applied: 200 px β fixed value from the slider. (Auto would have used 142 px.) With the filter off it says so plainly. The point about per-image variability is now on screen where whoever is counting will see it β which should make your decision on point 2 easier, since you'll see the number move between photos.
2. Segmentation time appears in three places: the segmentation info box (Segmentation time: 3.42 s at the top), appended to Analysis Results after viability, and in the Summary tab as a per-tab column plus mean and total:
Tab Total Live Dead Viab % Confl % Seg s
--------------------------------------------------------
1 74 67 7 90.5 1.5 3.42
2 80 74 6 92.5 1.6 2.98
3 69 61 8 88.4 1.4 3.11
4 77 70 7 90.9 1.5 3.05
--------------------------------------------------------
Mean 75.0 68.0 7.0 90.6 1.5 3.14
Total 12.56
It's also a column in the CSV. That Total row is the number your speed claim needs β total machine time for a four-square count, directly comparable to the hemocytometer timings in your Data_log image and the RWD's recorded times.
One caveat: the timer covers the whole run_segmentation call, so the first run after a rebuild includes ZeroGPU attaching and Cellpose loading the model. That one will be an outlier β discard it and use steady-state runs for the paper.
3. PDF export is a second button in Tab 5. It produces a multi-page A4 document:
Page 1 β the per-tab table, the mean, concentrations at both dilutions for live/dead/total, total segmentation time, and a red warning if fewer than four squares were used
Pages 2β5 β one landscape page per tab with raw, segmentation overlay, and viability overlay side by side, captioned with that tab's numbers
Built with matplotlib's PdfPages, so no new dependency. Images are thumbnailed to 700 px before storage β full frames across four tabs would bloat both session memory and the file. A four-tab export came out at 713 KB, which is fine to attach to a lab notebook or drop into supplementary material.
|
@@ -13,6 +13,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
| 13 |
import csv
|
| 14 |
import joblib
|
| 15 |
import os
|
|
|
|
| 16 |
|
| 17 |
HF_REPO_ID = "myang4218/cellposemodel"
|
| 18 |
HF_REPO_ID2 = "LiangLabUMB/viability_model"
|
|
@@ -647,6 +648,7 @@ def render_segmentation(base_masks, processed_image_np,
|
|
| 647 |
def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
| 648 |
use_stereology, left_exclusion, top_exclusion,
|
| 649 |
crop_points=None, use_min_filter=False, use_max_filter=False):
|
|
|
|
| 650 |
image_np = np.array(image)
|
| 651 |
|
| 652 |
# Crop BEFORE downscaling, so the ROI is sampled from the original pixels
|
|
@@ -701,15 +703,6 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
|
| 701 |
# Compute recommendation from RAW masks
|
| 702 |
recommend_min = rec_min_size(masks_raw)
|
| 703 |
|
| 704 |
-
# Surface the recommendation as text rather than writing it into the
|
| 705 |
-
# slider, which would overwrite a threshold the user had chosen.
|
| 706 |
-
if recommend_min > 0:
|
| 707 |
-
rec_msg = (f"*Recommended minimum: **{recommend_min}** pixels "
|
| 708 |
-
f"(25th percentile of detected object sizes). "
|
| 709 |
-
f"Enable the filter and leave the slider at 0 to use it.*")
|
| 710 |
-
else:
|
| 711 |
-
rec_msg = "*No objects detected β no minimum size recommendation.*"
|
| 712 |
-
|
| 713 |
# Size filters only run when their checkbox is ticked. When the minimum
|
| 714 |
# filter is on but the slider is still at 0, fall back to the
|
| 715 |
# recommendation; when it is off, nothing is filtered at all.
|
|
@@ -717,6 +710,28 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
|
| 717 |
if use_min_filter:
|
| 718 |
min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
|
| 719 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
masks = masks_raw.copy()
|
| 721 |
removed_small = 0
|
| 722 |
removed_large = 0
|
|
@@ -752,6 +767,9 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
|
| 752 |
info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
|
| 753 |
info_msg += "Now run the viability classification model for viability assessment."
|
| 754 |
|
|
|
|
|
|
|
|
|
|
| 755 |
return (
|
| 756 |
cell_count,
|
| 757 |
overlay_pil,
|
|
@@ -763,6 +781,8 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
|
| 763 |
gr.update(value=rec_msg),
|
| 764 |
pack_array(raw_image_np),
|
| 765 |
pack_array(base_masks),
|
|
|
|
|
|
|
| 766 |
)
|
| 767 |
|
| 768 |
except Exception as e:
|
|
@@ -779,6 +799,8 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
|
| 779 |
gr.update(),
|
| 780 |
None,
|
| 781 |
None,
|
|
|
|
|
|
|
| 782 |
)
|
| 783 |
|
| 784 |
|
|
@@ -850,19 +872,46 @@ def unpack_array(data):
|
|
| 850 |
return np.array(Image.open(buf))
|
| 851 |
|
| 852 |
|
| 853 |
-
def
|
| 854 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
def _f(v):
|
| 856 |
try:
|
| 857 |
return float(v) if v is not None else None
|
| 858 |
except (TypeError, ValueError):
|
| 859 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
return {
|
| 861 |
"cell_count": _f(cell_count),
|
| 862 |
"confluency": _f(confluency),
|
| 863 |
"viab_percent": _f(viab_percent),
|
| 864 |
"live": _f(live_cells),
|
| 865 |
"dead": _f(dead_cells),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
}
|
| 867 |
|
| 868 |
|
|
@@ -893,8 +942,10 @@ def compute_summary(r1, r2, r3, r4):
|
|
| 893 |
avg_viab = _avg("viab_percent")
|
| 894 |
avg_live = _avg("live")
|
| 895 |
avg_dead = _avg("dead")
|
|
|
|
| 896 |
|
| 897 |
-
header = f"{'Tab':<6}{'Total':>8}{'Live':>8}{'Dead':>8}
|
|
|
|
| 898 |
lines = [header, "-" * len(header)]
|
| 899 |
for tab_num, r in valid:
|
| 900 |
lines.append(
|
|
@@ -904,10 +955,13 @@ def compute_summary(r1, r2, r3, r4):
|
|
| 904 |
f"{(r.get('dead') or 0):>8.0f}"
|
| 905 |
f"{(r.get('viab_percent') or 0):>9.1f}"
|
| 906 |
f"{(r.get('confluency') or 0):>9.1f}"
|
|
|
|
| 907 |
)
|
| 908 |
lines.append("-" * len(header))
|
| 909 |
lines.append(f"{'Mean':<6}{avg_count:>8.1f}{avg_live:>8.1f}{avg_dead:>8.1f}"
|
| 910 |
-
f"{avg_viab:>9.1f}{avg_conf:>9.1f}")
|
|
|
|
|
|
|
| 911 |
lines.append("")
|
| 912 |
lines.append(f"Averaged over {n} tab{'s' if n > 1 else ''}"
|
| 913 |
+ ("" if n == 4 else f" β hemocytometer convention uses all 4 squares"))
|
|
@@ -944,22 +998,23 @@ def export_summary_csv(r1, r2, r3, r4):
|
|
| 944 |
return (sum(vals) / len(vals)) if vals else 0.0
|
| 945 |
|
| 946 |
means = {k: _avg(k) for k in
|
| 947 |
-
("cell_count", "live", "dead", "viab_percent", "confluency"
|
|
|
|
| 948 |
|
| 949 |
header = ["Tab", "Total cells", "Live cells", "Dead cells",
|
| 950 |
-
"Viability (%)", "Confluency (%)"]
|
| 951 |
for name, mult in CONC_PRESETS:
|
| 952 |
header += [f"Live conc {name} [x{mult}] (cells/mL)",
|
| 953 |
f"Dead conc {name} [x{mult}] (cells/mL)",
|
| 954 |
f"Total conc {name} [x{mult}] (cells/mL)"]
|
| 955 |
header += ["Tabs averaged", "Note"]
|
| 956 |
|
| 957 |
-
def _row(label, total, live, dead, viab, conf, n_avg="", note=""):
|
| 958 |
row = [label,
|
| 959 |
f"{total:.0f}" if label != "Mean" else f"{total:.2f}",
|
| 960 |
f"{live:.0f}" if label != "Mean" else f"{live:.2f}",
|
| 961 |
f"{dead:.0f}" if label != "Mean" else f"{dead:.2f}",
|
| 962 |
-
f"{viab:.1f}", f"{conf:.1f}"]
|
| 963 |
for _, mult in CONC_PRESETS:
|
| 964 |
row += [f"{live * mult:.0f}", f"{dead * mult:.0f}", f"{total * mult:.0f}"]
|
| 965 |
row += [n_avg, note]
|
|
@@ -968,14 +1023,15 @@ def export_summary_csv(r1, r2, r3, r4):
|
|
| 968 |
rows = [_row(str(tab),
|
| 969 |
r.get("cell_count") or 0.0, r.get("live") or 0.0,
|
| 970 |
r.get("dead") or 0.0, r.get("viab_percent") or 0.0,
|
| 971 |
-
r.get("confluency") or 0.0)
|
| 972 |
for tab, r in valid]
|
| 973 |
|
| 974 |
n = len(valid)
|
| 975 |
note = ("" if n == 4 else
|
| 976 |
"Fewer than 4 squares averaged - not the standard hemocytometer convention")
|
| 977 |
rows.append(_row("Mean", means["cell_count"], means["live"], means["dead"],
|
| 978 |
-
means["viab_percent"], means["confluency"],
|
|
|
|
| 979 |
|
| 980 |
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="")
|
| 981 |
w = csv.writer(tmp)
|
|
@@ -989,6 +1045,110 @@ def export_summary_csv(r1, r2, r3, r4):
|
|
| 989 |
return tmp.name, msg
|
| 990 |
|
| 991 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 992 |
# ---------------------------------------------------------------------------
|
| 993 |
# Training data export β feature extraction per cell
|
| 994 |
# ---------------------------------------------------------------------------
|
|
@@ -1428,6 +1588,9 @@ def build_tab(tab_index, masks_state, image_state, result_state):
|
|
| 1428 |
preview_scale_state = gr.State(value=1.0)
|
| 1429 |
# None = showing the overview; dict = showing a zoomed window
|
| 1430 |
zoom_state = gr.State(value=None)
|
|
|
|
|
|
|
|
|
|
| 1431 |
#raw image state
|
| 1432 |
raw_image_state = gr.State(value=None)
|
| 1433 |
# Masks as segmented, before stereological exclusion β lets the zones
|
|
@@ -1752,9 +1915,11 @@ def build_tab(tab_index, masks_state, image_state, result_state):
|
|
| 1752 |
|
| 1753 |
|
| 1754 |
# ---- Viability ------------------------------------------------------
|
| 1755 |
-
def on_run_viability(stored_masks, stored_image):
|
| 1756 |
overlay, alive, dead, viab_pct, info, label_map = run_viability(
|
| 1757 |
stored_masks, stored_image)
|
|
|
|
|
|
|
| 1758 |
return (overlay, alive, dead, viab_pct, info, label_map,
|
| 1759 |
format_concentration(alive, CONC_PRESETS[0][1]),
|
| 1760 |
format_concentration(alive, CONC_PRESETS[1][1]))
|
|
@@ -1765,12 +1930,13 @@ def build_tab(tab_index, masks_state, image_state, result_state):
|
|
| 1765 |
|
| 1766 |
viab_run_btn.click(
|
| 1767 |
fn=on_run_viability,
|
| 1768 |
-
inputs=[masks_state, image_state],
|
| 1769 |
outputs=viab_outputs
|
| 1770 |
).then(
|
| 1771 |
fn=save_tab_result,
|
| 1772 |
inputs=[cell_count_out, confluency_out, viab_percent_out,
|
| 1773 |
-
live_count_out, dead_count_out
|
|
|
|
| 1774 |
outputs=[result_state]
|
| 1775 |
)
|
| 1776 |
|
|
@@ -1781,18 +1947,20 @@ def build_tab(tab_index, masks_state, image_state, result_state):
|
|
| 1781 |
use_min_filter, use_max_filter],
|
| 1782 |
outputs=[cell_count_out, overlay_out, info_out, viability_section,
|
| 1783 |
masks_state, image_state, confluency_out,
|
| 1784 |
-
min_size_recommendation, raw_image_state, base_masks_state
|
|
|
|
| 1785 |
).then(
|
| 1786 |
# Viability is wanted on essentially every run, so do it without a
|
| 1787 |
# second button press. The button stays for re-running after the
|
| 1788 |
# exclusion sliders or the label corrections change.
|
| 1789 |
fn=on_run_viability,
|
| 1790 |
-
inputs=[masks_state, image_state],
|
| 1791 |
outputs=viab_outputs
|
| 1792 |
).then(
|
| 1793 |
fn=save_tab_result,
|
| 1794 |
inputs=[cell_count_out, confluency_out, viab_percent_out,
|
| 1795 |
-
live_count_out, dead_count_out
|
|
|
|
| 1796 |
outputs=[result_state]
|
| 1797 |
)
|
| 1798 |
|
|
@@ -1942,6 +2110,25 @@ with gr.Blocks(
|
|
| 1942 |
outputs=[summary_export_file, summary_export_info]
|
| 1943 |
)
|
| 1944 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1945 |
|
| 1946 |
|
| 1947 |
if __name__ == "__main__":
|
|
|
|
| 13 |
import csv
|
| 14 |
import joblib
|
| 15 |
import os
|
| 16 |
+
import time
|
| 17 |
|
| 18 |
HF_REPO_ID = "myang4218/cellposemodel"
|
| 19 |
HF_REPO_ID2 = "LiangLabUMB/viability_model"
|
|
|
|
| 648 |
def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
|
| 649 |
use_stereology, left_exclusion, top_exclusion,
|
| 650 |
crop_points=None, use_min_filter=False, use_max_filter=False):
|
| 651 |
+
_t_start = time.perf_counter()
|
| 652 |
image_np = np.array(image)
|
| 653 |
|
| 654 |
# Crop BEFORE downscaling, so the ROI is sampled from the original pixels
|
|
|
|
| 703 |
# Compute recommendation from RAW masks
|
| 704 |
recommend_min = rec_min_size(masks_raw)
|
| 705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
# Size filters only run when their checkbox is ticked. When the minimum
|
| 707 |
# filter is on but the slider is still at 0, fall back to the
|
| 708 |
# recommendation; when it is off, nothing is filtered at all.
|
|
|
|
| 710 |
if use_min_filter:
|
| 711 |
min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
|
| 712 |
|
| 713 |
+
# State what was ACTUALLY used, not what is recommended. The old wording
|
| 714 |
+
# ("enable the filter and leave the slider at 0 to use it") read as advice
|
| 715 |
+
# for next time while the threshold was already in force, and the slider
|
| 716 |
+
# still showing 0 reinforced that. Both signals said "off" when it was on.
|
| 717 |
+
if not use_min_filter:
|
| 718 |
+
rec_msg = (f"*Minimum size filter **off**. If enabled, this image "
|
| 719 |
+
f"would use **{recommend_min}** px "
|
| 720 |
+
f"(25th percentile of detected object sizes).*"
|
| 721 |
+
if recommend_min > 0 else
|
| 722 |
+
"*Minimum size filter off β no objects detected.*")
|
| 723 |
+
elif min_used <= 0:
|
| 724 |
+
rec_msg = "*Minimum size filter on, but no threshold could be derived.*"
|
| 725 |
+
elif min_cell_size == 0:
|
| 726 |
+
rec_msg = (f"*Minimum size filter **applied: {min_used} px** β auto, "
|
| 727 |
+
f"the 25th percentile of THIS image. Recomputed per image, "
|
| 728 |
+
f"so it differs between photos. Set the slider to a fixed "
|
| 729 |
+
f"value for reproducible counts.*")
|
| 730 |
+
else:
|
| 731 |
+
rec_msg = (f"*Minimum size filter **applied: {min_used} px** β fixed "
|
| 732 |
+
f"value from the slider. (Auto would have used "
|
| 733 |
+
f"{recommend_min} px.)*")
|
| 734 |
+
|
| 735 |
masks = masks_raw.copy()
|
| 736 |
removed_small = 0
|
| 737 |
removed_large = 0
|
|
|
|
| 767 |
info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
|
| 768 |
info_msg += "Now run the viability classification model for viability assessment."
|
| 769 |
|
| 770 |
+
seg_seconds = time.perf_counter() - _t_start
|
| 771 |
+
info_msg = f"Segmentation time: {seg_seconds:.2f} s\n" + info_msg
|
| 772 |
+
|
| 773 |
return (
|
| 774 |
cell_count,
|
| 775 |
overlay_pil,
|
|
|
|
| 781 |
gr.update(value=rec_msg),
|
| 782 |
pack_array(raw_image_np),
|
| 783 |
pack_array(base_masks),
|
| 784 |
+
round(float(seg_seconds), 2),
|
| 785 |
+
overlay_pil,
|
| 786 |
)
|
| 787 |
|
| 788 |
except Exception as e:
|
|
|
|
| 799 |
gr.update(),
|
| 800 |
None,
|
| 801 |
None,
|
| 802 |
+
0.0,
|
| 803 |
+
None,
|
| 804 |
)
|
| 805 |
|
| 806 |
|
|
|
|
| 872 |
return np.array(Image.open(buf))
|
| 873 |
|
| 874 |
|
| 875 |
+
def _thumb(img, box=700):
|
| 876 |
+
"""Small PIL copy for the PDF. Full-resolution frames across four tabs
|
| 877 |
+
would balloon both session memory and the exported file."""
|
| 878 |
+
if img is None:
|
| 879 |
+
return None
|
| 880 |
+
try:
|
| 881 |
+
im = img.copy() if isinstance(img, Image.Image) else Image.fromarray(np.asarray(img))
|
| 882 |
+
im.thumbnail((box, box), Image.LANCZOS)
|
| 883 |
+
return im.convert("RGB")
|
| 884 |
+
except Exception:
|
| 885 |
+
return None
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def save_tab_result(cell_count, confluency, viab_percent, live_cells, dead_cells,
|
| 889 |
+
seg_seconds=None, raw_packed=None, seg_overlay=None,
|
| 890 |
+
viab_overlay=None):
|
| 891 |
+
"""Package per-tab results (and frames for the PDF) for the Tab 5 summary."""
|
| 892 |
def _f(v):
|
| 893 |
try:
|
| 894 |
return float(v) if v is not None else None
|
| 895 |
except (TypeError, ValueError):
|
| 896 |
return None
|
| 897 |
+
|
| 898 |
+
raw_img = None
|
| 899 |
+
if raw_packed is not None:
|
| 900 |
+
try:
|
| 901 |
+
raw_img = _thumb(Image.fromarray(unpack_array(raw_packed)))
|
| 902 |
+
except Exception:
|
| 903 |
+
raw_img = None
|
| 904 |
+
|
| 905 |
return {
|
| 906 |
"cell_count": _f(cell_count),
|
| 907 |
"confluency": _f(confluency),
|
| 908 |
"viab_percent": _f(viab_percent),
|
| 909 |
"live": _f(live_cells),
|
| 910 |
"dead": _f(dead_cells),
|
| 911 |
+
"seg_seconds": _f(seg_seconds),
|
| 912 |
+
"img_raw": raw_img,
|
| 913 |
+
"img_seg": _thumb(seg_overlay),
|
| 914 |
+
"img_viab": _thumb(viab_overlay),
|
| 915 |
}
|
| 916 |
|
| 917 |
|
|
|
|
| 942 |
avg_viab = _avg("viab_percent")
|
| 943 |
avg_live = _avg("live")
|
| 944 |
avg_dead = _avg("dead")
|
| 945 |
+
avg_secs = _avg("seg_seconds")
|
| 946 |
|
| 947 |
+
header = (f"{'Tab':<6}{'Total':>8}{'Live':>8}{'Dead':>8}"
|
| 948 |
+
f"{'Viab %':>9}{'Confl %':>9}{'Seg s':>8}")
|
| 949 |
lines = [header, "-" * len(header)]
|
| 950 |
for tab_num, r in valid:
|
| 951 |
lines.append(
|
|
|
|
| 955 |
f"{(r.get('dead') or 0):>8.0f}"
|
| 956 |
f"{(r.get('viab_percent') or 0):>9.1f}"
|
| 957 |
f"{(r.get('confluency') or 0):>9.1f}"
|
| 958 |
+
f"{(r.get('seg_seconds') or 0):>8.2f}"
|
| 959 |
)
|
| 960 |
lines.append("-" * len(header))
|
| 961 |
lines.append(f"{'Mean':<6}{avg_count:>8.1f}{avg_live:>8.1f}{avg_dead:>8.1f}"
|
| 962 |
+
f"{avg_viab:>9.1f}{avg_conf:>9.1f}{avg_secs:>8.2f}")
|
| 963 |
+
total_secs = sum(r.get("seg_seconds") or 0 for _, r in valid)
|
| 964 |
+
lines.append(f"{'Total':<6}{'':>8}{'':>8}{'':>8}{'':>9}{'':>9}{total_secs:>8.2f}")
|
| 965 |
lines.append("")
|
| 966 |
lines.append(f"Averaged over {n} tab{'s' if n > 1 else ''}"
|
| 967 |
+ ("" if n == 4 else f" β hemocytometer convention uses all 4 squares"))
|
|
|
|
| 998 |
return (sum(vals) / len(vals)) if vals else 0.0
|
| 999 |
|
| 1000 |
means = {k: _avg(k) for k in
|
| 1001 |
+
("cell_count", "live", "dead", "viab_percent", "confluency",
|
| 1002 |
+
"seg_seconds")}
|
| 1003 |
|
| 1004 |
header = ["Tab", "Total cells", "Live cells", "Dead cells",
|
| 1005 |
+
"Viability (%)", "Confluency (%)", "Segmentation time (s)"]
|
| 1006 |
for name, mult in CONC_PRESETS:
|
| 1007 |
header += [f"Live conc {name} [x{mult}] (cells/mL)",
|
| 1008 |
f"Dead conc {name} [x{mult}] (cells/mL)",
|
| 1009 |
f"Total conc {name} [x{mult}] (cells/mL)"]
|
| 1010 |
header += ["Tabs averaged", "Note"]
|
| 1011 |
|
| 1012 |
+
def _row(label, total, live, dead, viab, conf, secs=0.0, n_avg="", note=""):
|
| 1013 |
row = [label,
|
| 1014 |
f"{total:.0f}" if label != "Mean" else f"{total:.2f}",
|
| 1015 |
f"{live:.0f}" if label != "Mean" else f"{live:.2f}",
|
| 1016 |
f"{dead:.0f}" if label != "Mean" else f"{dead:.2f}",
|
| 1017 |
+
f"{viab:.1f}", f"{conf:.1f}", f"{secs:.2f}"]
|
| 1018 |
for _, mult in CONC_PRESETS:
|
| 1019 |
row += [f"{live * mult:.0f}", f"{dead * mult:.0f}", f"{total * mult:.0f}"]
|
| 1020 |
row += [n_avg, note]
|
|
|
|
| 1023 |
rows = [_row(str(tab),
|
| 1024 |
r.get("cell_count") or 0.0, r.get("live") or 0.0,
|
| 1025 |
r.get("dead") or 0.0, r.get("viab_percent") or 0.0,
|
| 1026 |
+
r.get("confluency") or 0.0, r.get("seg_seconds") or 0.0)
|
| 1027 |
for tab, r in valid]
|
| 1028 |
|
| 1029 |
n = len(valid)
|
| 1030 |
note = ("" if n == 4 else
|
| 1031 |
"Fewer than 4 squares averaged - not the standard hemocytometer convention")
|
| 1032 |
rows.append(_row("Mean", means["cell_count"], means["live"], means["dead"],
|
| 1033 |
+
means["viab_percent"], means["confluency"],
|
| 1034 |
+
means["seg_seconds"], str(n), note))
|
| 1035 |
|
| 1036 |
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="")
|
| 1037 |
w = csv.writer(tmp)
|
|
|
|
| 1045 |
return tmp.name, msg
|
| 1046 |
|
| 1047 |
|
| 1048 |
+
def export_summary_pdf(r1, r2, r3, r4):
|
| 1049 |
+
"""Multi-page PDF: summary page, then one page per tab with the raw frame,
|
| 1050 |
+
the segmentation overlay and the viability overlay side by side.
|
| 1051 |
+
|
| 1052 |
+
Uses matplotlib's PdfPages (already a dependency) rather than adding a PDF
|
| 1053 |
+
library, and embeds the images so the file is self-contained for a lab
|
| 1054 |
+
notebook or a supplementary figure.
|
| 1055 |
+
|
| 1056 |
+
Returns (path_or_None, status_message).
|
| 1057 |
+
"""
|
| 1058 |
+
from matplotlib.backends.backend_pdf import PdfPages
|
| 1059 |
+
|
| 1060 |
+
results = [r1, r2, r3, r4]
|
| 1061 |
+
valid = [(i + 1, r) for i, r in enumerate(results)
|
| 1062 |
+
if r is not None and r.get("cell_count") is not None]
|
| 1063 |
+
if not valid:
|
| 1064 |
+
return None, "No data to export β run segmentation in at least one tab first."
|
| 1065 |
+
|
| 1066 |
+
def _avg(key):
|
| 1067 |
+
vals = [r.get(key) for _, r in valid if r.get(key) is not None]
|
| 1068 |
+
return (sum(vals) / len(vals)) if vals else 0.0
|
| 1069 |
+
|
| 1070 |
+
n = len(valid)
|
| 1071 |
+
avg_count, avg_live = _avg("cell_count"), _avg("live")
|
| 1072 |
+
avg_dead, avg_viab = _avg("dead"), _avg("viab_percent")
|
| 1073 |
+
avg_conf, avg_secs = _avg("confluency"), _avg("seg_seconds")
|
| 1074 |
+
|
| 1075 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
|
| 1076 |
+
tmp.close()
|
| 1077 |
+
|
| 1078 |
+
with PdfPages(tmp.name) as pdf:
|
| 1079 |
+
# ---------- page 1: numbers ----------
|
| 1080 |
+
fig = plt.figure(figsize=(8.27, 11.69)) # A4 portrait
|
| 1081 |
+
fig.text(0.06, 0.95, "CellposeCellCounter β Session Summary",
|
| 1082 |
+
fontsize=16, fontweight="bold")
|
| 1083 |
+
|
| 1084 |
+
rows = [f"{'Tab':<6}{'Total':>8}{'Live':>8}{'Dead':>8}"
|
| 1085 |
+
f"{'Viab %':>9}{'Confl %':>9}{'Seg s':>8}",
|
| 1086 |
+
"-" * 56]
|
| 1087 |
+
for tab, r in valid:
|
| 1088 |
+
rows.append(f"{tab:<6}{(r.get('cell_count') or 0):>8.0f}"
|
| 1089 |
+
f"{(r.get('live') or 0):>8.0f}{(r.get('dead') or 0):>8.0f}"
|
| 1090 |
+
f"{(r.get('viab_percent') or 0):>9.1f}"
|
| 1091 |
+
f"{(r.get('confluency') or 0):>9.1f}"
|
| 1092 |
+
f"{(r.get('seg_seconds') or 0):>8.2f}")
|
| 1093 |
+
rows += ["-" * 56,
|
| 1094 |
+
f"{'Mean':<6}{avg_count:>8.1f}{avg_live:>8.1f}{avg_dead:>8.1f}"
|
| 1095 |
+
f"{avg_viab:>9.1f}{avg_conf:>9.1f}{avg_secs:>8.2f}"]
|
| 1096 |
+
fig.text(0.06, 0.72, "\n".join(rows), fontsize=9,
|
| 1097 |
+
family="monospace", va="top")
|
| 1098 |
+
|
| 1099 |
+
conc = ["Cell concentration (from the mean across tabs)", ""]
|
| 1100 |
+
for name, mult in CONC_PRESETS:
|
| 1101 |
+
conc += [f"{name} (x {mult:,})",
|
| 1102 |
+
f" Live {avg_live:8.1f} -> {avg_live * mult:>14,.0f} cells/mL",
|
| 1103 |
+
f" Dead {avg_dead:8.1f} -> {avg_dead * mult:>14,.0f} cells/mL",
|
| 1104 |
+
f" Total {avg_count:8.1f} -> {avg_count * mult:>14,.0f} cells/mL",
|
| 1105 |
+
""]
|
| 1106 |
+
fig.text(0.06, 0.50, "\n".join(conc), fontsize=9,
|
| 1107 |
+
family="monospace", va="top")
|
| 1108 |
+
|
| 1109 |
+
foot = [f"Tabs averaged: {n} of 4",
|
| 1110 |
+
f"Total segmentation time: "
|
| 1111 |
+
f"{sum(r.get('seg_seconds') or 0 for _, r in valid):.2f} s"]
|
| 1112 |
+
if n != 4:
|
| 1113 |
+
foot.append("WARNING: fewer than 4 squares β not the standard "
|
| 1114 |
+
"hemocytometer convention")
|
| 1115 |
+
fig.text(0.06, 0.16, "\n".join(foot), fontsize=9,
|
| 1116 |
+
family="monospace", va="top",
|
| 1117 |
+
color=("crimson" if n != 4 else "black"))
|
| 1118 |
+
pdf.savefig(fig); plt.close(fig)
|
| 1119 |
+
|
| 1120 |
+
# ---------- one page per tab ----------
|
| 1121 |
+
panels = [("Raw (as segmented)", "img_raw"),
|
| 1122 |
+
("Segmentation", "img_seg"),
|
| 1123 |
+
("Viability (green=live, red=dead)", "img_viab")]
|
| 1124 |
+
for tab, r in valid:
|
| 1125 |
+
fig = plt.figure(figsize=(11.69, 8.27)) # A4 landscape
|
| 1126 |
+
fig.suptitle(f"Tab {tab}", fontsize=15, fontweight="bold")
|
| 1127 |
+
for i, (title, key) in enumerate(panels, start=1):
|
| 1128 |
+
ax = fig.add_subplot(1, 3, i)
|
| 1129 |
+
img = r.get(key)
|
| 1130 |
+
if img is not None:
|
| 1131 |
+
ax.imshow(img)
|
| 1132 |
+
else:
|
| 1133 |
+
ax.text(0.5, 0.5, "not available", ha="center",
|
| 1134 |
+
va="center", fontsize=10, color="grey")
|
| 1135 |
+
ax.set_title(title, fontsize=10)
|
| 1136 |
+
ax.axis("off")
|
| 1137 |
+
cap = (f"Total {(r.get('cell_count') or 0):.0f} "
|
| 1138 |
+
f"Live {(r.get('live') or 0):.0f} "
|
| 1139 |
+
f"Dead {(r.get('dead') or 0):.0f} "
|
| 1140 |
+
f"Viability {(r.get('viab_percent') or 0):.1f}% "
|
| 1141 |
+
f"Confluency {(r.get('confluency') or 0):.1f}% "
|
| 1142 |
+
f"Segmentation {(r.get('seg_seconds') or 0):.2f} s")
|
| 1143 |
+
fig.text(0.5, 0.06, cap, ha="center", fontsize=9, family="monospace")
|
| 1144 |
+
pdf.savefig(fig); plt.close(fig)
|
| 1145 |
+
|
| 1146 |
+
msg = f"Exported PDF: summary page + {n} tab page{'s' if n > 1 else ''}."
|
| 1147 |
+
if n != 4:
|
| 1148 |
+
msg += " β Fewer than 4 squares averaged."
|
| 1149 |
+
return tmp.name, msg
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
# ---------------------------------------------------------------------------
|
| 1153 |
# Training data export β feature extraction per cell
|
| 1154 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1588 |
preview_scale_state = gr.State(value=1.0)
|
| 1589 |
# None = showing the overview; dict = showing a zoomed window
|
| 1590 |
zoom_state = gr.State(value=None)
|
| 1591 |
+
# segmentation wall time and the overlay frame, both needed by Tab 5
|
| 1592 |
+
seg_time_state = gr.State(value=0.0)
|
| 1593 |
+
seg_overlay_state = gr.State(value=None)
|
| 1594 |
#raw image state
|
| 1595 |
raw_image_state = gr.State(value=None)
|
| 1596 |
# Masks as segmented, before stereological exclusion β lets the zones
|
|
|
|
| 1915 |
|
| 1916 |
|
| 1917 |
# ---- Viability ------------------------------------------------------
|
| 1918 |
+
def on_run_viability(stored_masks, stored_image, seg_seconds=None):
|
| 1919 |
overlay, alive, dead, viab_pct, info, label_map = run_viability(
|
| 1920 |
stored_masks, stored_image)
|
| 1921 |
+
if seg_seconds:
|
| 1922 |
+
info = f"{info}\nSegmentation time: {float(seg_seconds):.2f} s"
|
| 1923 |
return (overlay, alive, dead, viab_pct, info, label_map,
|
| 1924 |
format_concentration(alive, CONC_PRESETS[0][1]),
|
| 1925 |
format_concentration(alive, CONC_PRESETS[1][1]))
|
|
|
|
| 1930 |
|
| 1931 |
viab_run_btn.click(
|
| 1932 |
fn=on_run_viability,
|
| 1933 |
+
inputs=[masks_state, image_state, seg_time_state],
|
| 1934 |
outputs=viab_outputs
|
| 1935 |
).then(
|
| 1936 |
fn=save_tab_result,
|
| 1937 |
inputs=[cell_count_out, confluency_out, viab_percent_out,
|
| 1938 |
+
live_count_out, dead_count_out, seg_time_state,
|
| 1939 |
+
raw_image_state, seg_overlay_state, viab_overlay],
|
| 1940 |
outputs=[result_state]
|
| 1941 |
)
|
| 1942 |
|
|
|
|
| 1947 |
use_min_filter, use_max_filter],
|
| 1948 |
outputs=[cell_count_out, overlay_out, info_out, viability_section,
|
| 1949 |
masks_state, image_state, confluency_out,
|
| 1950 |
+
min_size_recommendation, raw_image_state, base_masks_state,
|
| 1951 |
+
seg_time_state, seg_overlay_state]
|
| 1952 |
).then(
|
| 1953 |
# Viability is wanted on essentially every run, so do it without a
|
| 1954 |
# second button press. The button stays for re-running after the
|
| 1955 |
# exclusion sliders or the label corrections change.
|
| 1956 |
fn=on_run_viability,
|
| 1957 |
+
inputs=[masks_state, image_state, seg_time_state],
|
| 1958 |
outputs=viab_outputs
|
| 1959 |
).then(
|
| 1960 |
fn=save_tab_result,
|
| 1961 |
inputs=[cell_count_out, confluency_out, viab_percent_out,
|
| 1962 |
+
live_count_out, dead_count_out, seg_time_state,
|
| 1963 |
+
raw_image_state, seg_overlay_state, viab_overlay],
|
| 1964 |
outputs=[result_state]
|
| 1965 |
)
|
| 1966 |
|
|
|
|
| 2110 |
outputs=[summary_export_file, summary_export_info]
|
| 2111 |
)
|
| 2112 |
|
| 2113 |
+
with gr.Row():
|
| 2114 |
+
summary_pdf_btn = gr.Button("π Export summary PDF (with images)",
|
| 2115 |
+
variant="secondary")
|
| 2116 |
+
summary_pdf_info = gr.Textbox(label="PDF status", lines=2,
|
| 2117 |
+
interactive=False)
|
| 2118 |
+
summary_pdf_file = gr.File(label="Download summary PDF", visible=False)
|
| 2119 |
+
|
| 2120 |
+
def on_export_summary_pdf(*tab_results):
|
| 2121 |
+
path, msg = export_summary_pdf(*tab_results)
|
| 2122 |
+
if path is None:
|
| 2123 |
+
return gr.update(visible=False), msg
|
| 2124 |
+
return gr.update(value=path, visible=True), msg
|
| 2125 |
+
|
| 2126 |
+
summary_pdf_btn.click(
|
| 2127 |
+
fn=on_export_summary_pdf,
|
| 2128 |
+
inputs=result_states,
|
| 2129 |
+
outputs=[summary_pdf_file, summary_pdf_info]
|
| 2130 |
+
)
|
| 2131 |
+
|
| 2132 |
|
| 2133 |
|
| 2134 |
if __name__ == "__main__":
|