LiangLabUMB commited on
Commit
5bb69eb
Β·
verified Β·
1 Parent(s): 40e61f3

Upload app.py

Browse files

three 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.

Files changed (1) hide show
  1. app.py +212 -25
app.py CHANGED
@@ -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 save_tab_result(cell_count, confluency, viab_percent, live_cells, dead_cells):
854
- """Package per-tab results into a dict for Tab 5 summary."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}{'Viab %':>9}{'Confl %':>9}"
 
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"], str(n), note))
 
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__":