LiangLabUMB commited on
Commit
334f44c
Β·
verified Β·
1 Parent(s): dce2f78

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +528 -85
app.py CHANGED
@@ -8,7 +8,8 @@ import tempfile
8
  from PIL import Image, ImageDraw
9
  import io
10
  from huggingface_hub import hf_hub_download
11
- import base64
 
12
 
13
  HF_REPO_ID = "myang4218/cellposemodel"
14
  MODEL_OPTIONS = {
@@ -18,11 +19,16 @@ MODEL_OPTIONS = {
18
 
19
  loaded_models = {}
20
 
 
21
  MAX_SIDE = 1024
22
  MAX_PIXELS = 1024 * 1024
23
 
24
 
25
  def safe_resize(image_np):
 
 
 
 
26
  h, w = image_np.shape[:2]
27
  total = h * w
28
 
@@ -44,27 +50,29 @@ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
44
 
45
  h, w = image_np.shape[:2]
46
 
47
- # Convert to PIL
48
  img_pil = Image.fromarray(image_np)
49
  draw = ImageDraw.Draw(img_pil, 'RGBA')
50
 
51
-
52
  left_px = int(w * left_width_pct / 100)
53
  top_px = int(h * top_width_pct / 100)
54
 
55
  # Draw overlays for exclusion zones
56
  if left_px > 0:
 
57
  draw.rectangle(
58
  [(0, 0), (left_px, h)],
59
- fill=(255, 0, 0, 80)
60
  )
61
  # border line
62
  draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
63
 
64
  if top_px > 0:
 
65
  draw.rectangle(
66
  [(0, 0), (w, top_px)],
67
- fill=(255, 0, 0, 80)
68
  )
69
  # border line
70
  draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
@@ -75,6 +83,7 @@ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
75
  def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
76
  h, w = masks.shape
77
 
 
78
  left_px = int(w * left_width_pct / 100)
79
  top_px = int(h * top_width_pct / 100)
80
 
@@ -91,14 +100,20 @@ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
91
  # Get cell boundary coordinates
92
  rows, cols = np.where(cell_mask)
93
 
 
94
  touches_left = np.any(cols < left_px) if left_px > 0 else False
 
 
95
  touches_top = np.any(rows < top_px) if top_px > 0 else False
96
 
 
97
  if touches_left or touches_top:
98
  filtered_masks[cell_mask] = 0
99
  excluded_cells.append(cell_id)
100
  else:
101
  included_cells.append(cell_id)
 
 
102
  unique_ids = np.unique(filtered_masks)
103
  unique_ids = unique_ids[unique_ids > 0]
104
 
@@ -109,67 +124,83 @@ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
109
  return renumbered_masks, len(excluded_cells), len(included_cells)
110
 
111
 
112
- def classify_cells_by_blueness(image_np, masks, blue_threshold):
113
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  if len(image_np.shape) == 2:
115
  image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
116
  elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
117
  image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
118
 
119
-
120
  hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
121
 
122
- # Calculate blueness index for each pixel
123
- hue = hsv[:, :, 0].astype(np.float32)
124
  saturation = hsv[:, :, 1].astype(np.float32)
125
 
126
- # Hue score: peaks around 115 , drops off towards edges
127
- # Handle hue wrap-around for blue detection (100-130 range)
128
  hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
129
- hue_score = np.maximum(0, 1 - hue_distance / 65) # 65 good blue range
 
130
 
131
- # blueness metric
132
- blueness = hue_score * (saturation / 255.0)
133
-
134
- # Convert threshold from 0-100 to 0-1 scale
135
- threshold = blue_threshold / 100.0
136
-
137
- # Get unique cell IDs
138
  cell_ids = np.unique(masks)
139
- cell_ids = cell_ids[cell_ids > 0]
140
 
141
- dead_cells = []
142
- alive_cells = []
 
143
 
144
- # Classify each cell
145
- for cell_id in cell_ids:
146
-
147
- cell_mask = (masks == cell_id)
148
- cell_blueness = np.mean(blueness[cell_mask])
149
 
150
- if cell_blueness > threshold:
151
- dead_cells.append(cell_id)
152
- else:
153
- alive_cells.append(cell_id)
154
 
155
- # Create colored overlay
156
- overlay = image_np.copy().astype(np.float32) # Ensure float for blending
 
 
 
 
 
 
 
 
157
 
158
- # Color dead cells red, alive cells green
159
- for cell_id in dead_cells:
160
- cell_mask = (masks == cell_id)
161
- overlay[cell_mask] = [255, 0, 0]
162
 
163
- for cell_id in alive_cells:
164
- cell_mask = (masks == cell_id)
165
- overlay[cell_mask] = [0, 255, 0]
 
 
 
 
 
 
 
166
 
167
- # Blend with original image
168
- alpha = 0.4
169
  final_overlay = (1 - alpha) * image_np.astype(np.float32) + alpha * overlay
170
  final_overlay = np.clip(final_overlay, 0, 255).astype(np.uint8)
171
 
172
- return len(dead_cells), len(alive_cells), final_overlay
173
 
174
 
175
  def measure_confluency(masks, image_np):
@@ -234,6 +265,44 @@ def rec_min_size(masks, q=25):
234
  return int(round(np.percentile(sizes, q)))
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  def toggle_stereological_mode(use_stereology):
238
  """Show/hide stereological controls based on checkbox"""
239
  return gr.update(visible=use_stereology)
@@ -249,21 +318,171 @@ def update_exclusion_preview(image, left_width, top_width):
249
  return Image.fromarray(overlay)
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  @spaces.GPU
253
- def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
254
- use_stereology, left_exclusion, top_exclusion):
 
255
  image_np = np.array(image)
256
  image_np = safe_resize(image_np)
 
 
 
 
 
257
 
258
  try:
259
  model_filename = MODEL_OPTIONS[model_choice]
260
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
261
-
262
- if model_filename in loaded_models:
263
- model = loaded_models[model_filename]
264
- else:
265
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
266
- loaded_models[model_filename] = model
267
 
268
  # Process image format to RGB
269
  if len(image_np.shape) == 2:
@@ -273,8 +492,8 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
273
  else:
274
  processed_image_np = image_np
275
 
276
- # Run Cellpose segmentation
277
- masks_raw, flows, styles = model.eval(processed_image_np, diameter=None, channels=[0, 0])
278
 
279
  ids = np.unique(masks_raw)
280
  ids = ids[ids > 0]
@@ -322,10 +541,10 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
322
  cell_count = len(np.unique(masks)) - 1
323
  confluency = measure_confluency(masks, processed_image_np)
324
 
325
- # Create segmentation overlay (without viability)
326
  segmentation_overlay = processed_image_np.copy().astype(np.float32)
327
  if masks.max() > 0:
328
- np.random.seed(42)
329
  colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
330
  colors[0] = [0, 0, 0]
331
  colored_mask = colors[masks]
@@ -342,6 +561,7 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
342
  info_msg += filter_msg
343
  info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
344
  info_msg += f"Confluency: {confluency:.1f}%\n"
 
345
  if use_stereology:
346
  info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
347
  info_msg += "Now adjust the Blue Threshold for viability assessment."
@@ -372,26 +592,28 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
372
  )
373
 
374
 
375
- def update_viability_realtime(blue_threshold, stored_masks, stored_image_np):
376
  # avoid unpacking None (e.g. slider moved before segmentation)
377
  if stored_masks is None or stored_image_np is None:
378
  return None, 0, 0, 0.0, "Please run segmentation first."
379
 
380
- stored_masks = unpack_array(stored_masks)
381
  stored_image_np = unpack_array(stored_image_np)
382
 
383
  try:
384
- dead_count, alive_count, viability_overlay_np = classify_cells_by_blueness(
385
- stored_image_np, stored_masks, blue_threshold
386
- )
387
 
388
- total_count = alive_count + dead_count
389
  viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
390
- confluency = measure_confluency(stored_masks, stored_image_np)
391
 
392
  overlay_image = Image.fromarray(viability_overlay_np)
393
- info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
394
- info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\nBlue threshold: {blue_threshold}%"
 
 
 
395
 
396
  return overlay_image, alive_count, dead_count, viability_percent, info_msg
397
 
@@ -447,6 +669,211 @@ def compute_summary(r1, r2, r3, r4):
447
  return avg_count, avg_conf, avg_viab, "\n".join(lines)
448
 
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  def build_tab(tab_index, masks_state, image_state, result_state):
451
  with gr.Tab(f"Tab {tab_index}"):
452
  gr.Markdown("Run segmentation")
@@ -455,11 +882,22 @@ def build_tab(tab_index, masks_state, image_state, result_state):
455
  with gr.Column():
456
  img_input = gr.Image(
457
  type="pil",
458
- label="Microscopy image",
459
  image_mode="RGB",
460
  height=512
461
  )
462
 
 
 
 
 
 
 
 
 
 
 
 
463
  model_dropdown = gr.Dropdown(
464
  choices=list(MODEL_OPTIONS.keys()),
465
  label="Select Model",
@@ -531,17 +969,20 @@ def build_tab(tab_index, masks_state, image_state, result_state):
531
 
532
  with gr.Group(visible=False) as viability_section:
533
  gr.Markdown("### Viability Assessment (Trypan Blue)")
534
- gr.Markdown("Adjust the threshold to classify cells as live (green) or dead (red).")
 
 
 
535
 
536
  with gr.Row():
537
  with gr.Column():
538
- blue_thresh = gr.Slider(
539
- minimum=0,
540
- maximum=100,
541
- value=25,
542
  step=1,
543
- label="Blue Threshold (%)",
544
- info="Higher values = more selective for blue cells"
545
  )
546
  with gr.Column():
547
  live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
@@ -549,9 +990,9 @@ def build_tab(tab_index, masks_state, image_state, result_state):
549
 
550
  viab_overlay = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
551
  viab_percent_out = gr.Number(label="Viability (%)", precision=1)
552
- viab_info = gr.Textbox(label="Analysis Results", lines=5)
553
 
554
- # Event handlers
555
 
556
  use_stereo.change(
557
  fn=toggle_stereological_mode,
@@ -578,33 +1019,33 @@ def build_tab(tab_index, masks_state, image_state, result_state):
578
  segment_btn.click(
579
  fn=run_segmentation,
580
  inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
581
- use_stereo, left_excl, top_excl],
582
  outputs=[cell_count_out, overlay_out, info_out, viability_section,
583
  masks_state, image_state, confluency_out, min_size_slider]
584
  ).then(
585
  fn=update_viability_realtime,
586
- inputs=[blue_thresh, masks_state, image_state],
587
  outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
588
  ).then(
589
- # Save this tab's results into its dedicated state for Tab 5
590
  fn=save_tab_result,
591
  inputs=[cell_count_out, confluency_out, viab_percent_out],
592
  outputs=[result_state]
593
  )
594
 
595
- blue_thresh.change(
596
  fn=update_viability_realtime,
597
- inputs=[blue_thresh, masks_state, image_state],
598
  outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
599
  ).then(
600
- # Keep result state in sync when threshold is adjusted
601
  fn=save_tab_result,
602
  inputs=[cell_count_out, confluency_out, viab_percent_out],
603
  outputs=[result_state]
604
  )
605
 
606
 
607
-
 
 
608
  with gr.Blocks(
609
  title="CellposeCellCounter",
610
  theme=gr.themes.Soft(),
@@ -621,7 +1062,9 @@ with gr.Blocks(
621
  for i in range(4):
622
  build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
623
 
624
- # Tab 5
 
 
625
  with gr.Tab("Tab 5 β€” Summary"):
626
  gr.Markdown("## Average Results Across All Tabs")
627
  gr.Markdown(
@@ -629,7 +1072,7 @@ with gr.Blocks(
629
  "then click **Refresh Summary** to see the averages."
630
  )
631
 
632
- refresh_btn = gr.Button("Refresh Summary", variant="primary", size="lg")
633
 
634
  with gr.Row():
635
  avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
 
8
  from PIL import Image, ImageDraw
9
  import io
10
  from huggingface_hub import hf_hub_download
11
+ import base64
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
 
14
  HF_REPO_ID = "myang4218/cellposemodel"
15
  MODEL_OPTIONS = {
 
19
 
20
  loaded_models = {}
21
 
22
+ # ---- mobile-safe size limits (aggressive for Safari) ----
23
  MAX_SIDE = 1024
24
  MAX_PIXELS = 1024 * 1024
25
 
26
 
27
  def safe_resize(image_np):
28
+ """
29
+ Downscale image to fit within MAX_SIDE and MAX_PIXELS while
30
+ preserving aspect ratio. Works for RGB / RGBA / grayscale.
31
+ """
32
  h, w = image_np.shape[:2]
33
  total = h * w
34
 
 
50
 
51
  h, w = image_np.shape[:2]
52
 
53
+ # Convert to PIL for drawing
54
  img_pil = Image.fromarray(image_np)
55
  draw = ImageDraw.Draw(img_pil, 'RGBA')
56
 
57
+ # Calculate pixel widths from percentages
58
  left_px = int(w * left_width_pct / 100)
59
  top_px = int(h * top_width_pct / 100)
60
 
61
  # Draw overlays for exclusion zones
62
  if left_px > 0:
63
+ # Left exclusion zone
64
  draw.rectangle(
65
  [(0, 0), (left_px, h)],
66
+ fill=(255, 0, 0, 80) # Semi-transparent red
67
  )
68
  # border line
69
  draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
70
 
71
  if top_px > 0:
72
+ # Top exclusion zone
73
  draw.rectangle(
74
  [(0, 0), (w, top_px)],
75
+ fill=(255, 0, 0, 80) # Semi-transparent red
76
  )
77
  # border line
78
  draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
 
83
  def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
84
  h, w = masks.shape
85
 
86
+ # Calculate pixel widths from percentages
87
  left_px = int(w * left_width_pct / 100)
88
  top_px = int(h * top_width_pct / 100)
89
 
 
100
  # Get cell boundary coordinates
101
  rows, cols = np.where(cell_mask)
102
 
103
+ # Check if cell touches left exclusion zone
104
  touches_left = np.any(cols < left_px) if left_px > 0 else False
105
+
106
+ # Check if cell touches top exclusion zone
107
  touches_top = np.any(rows < top_px) if top_px > 0 else False
108
 
109
+ # Exclude if touching left or top
110
  if touches_left or touches_top:
111
  filtered_masks[cell_mask] = 0
112
  excluded_cells.append(cell_id)
113
  else:
114
  included_cells.append(cell_id)
115
+
116
+ # Renumber remaining cells
117
  unique_ids = np.unique(filtered_masks)
118
  unique_ids = unique_ids[unique_ids > 0]
119
 
 
124
  return renumbered_masks, len(excluded_cells), len(included_cells)
125
 
126
 
127
+ def classify_cells_by_blueness(image_np, masks, threshold_bias):
128
+ """
129
+ Classify cells as dead (blue) or alive using an adaptive Otsu threshold
130
+ on per-cell blueness scores, with a user bias to fine-tune.
131
+
132
+ Args:
133
+ image_np: RGB image array
134
+ masks: Cellpose segmentation masks
135
+ threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
136
+ Negative = more cells classified dead (looser).
137
+ Positive = fewer cells classified dead (stricter).
138
+ 0 = pure Otsu (fully automatic).
139
+
140
+ Returns:
141
+ dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
142
+ """
143
+
144
  if len(image_np.shape) == 2:
145
  image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
146
  elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
147
  image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
148
 
 
149
  hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
150
 
151
+ hue = hsv[:, :, 0].astype(np.float32)
 
152
  saturation = hsv[:, :, 1].astype(np.float32)
153
 
154
+ # Raw blueness: hue proximity to 115Β° Γ— saturation
 
155
  hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
156
+ hue_score = np.maximum(0, 1 - hue_distance / 65)
157
+ blueness = hue_score * (saturation / 255.0)
158
 
159
+ # --- Compute per-cell mean blueness scores ---
 
 
 
 
 
 
160
  cell_ids = np.unique(masks)
161
+ cell_ids = cell_ids[cell_ids > 0]
162
 
163
+ if len(cell_ids) == 0:
164
+ blank = image_np.copy()
165
+ return 0, 0, blank, 0.0, 0.0
166
 
167
+ cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
 
 
 
 
168
 
169
+ # --- Otsu on the distribution of per-cell scores ---
170
+ # cv2.threshold expects uint8; scale 0-1 β†’ 0-255
171
+ scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
 
172
 
173
+ if scores_u8.max() == scores_u8.min():
174
+ # All cells identical β†’ Otsu is undefined; use midpoint
175
+ otsu_threshold = float(scores_u8[0]) / 255.0
176
+ else:
177
+ # Reshape to a single-column image so cv2.threshold works
178
+ thresh_val, _ = cv2.threshold(
179
+ scores_u8.reshape(-1, 1), 0, 255,
180
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU
181
+ )
182
+ otsu_threshold = thresh_val / 255.0
183
 
184
+ # --- Apply user bias: slider -50..+50 maps to Β±0.20 shift ---
185
+ bias = (threshold_bias / 50.0) * 0.20
186
+ final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
 
187
 
188
+ # --- Classify ---
189
+ dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
190
+ alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
191
+
192
+ # --- Colored overlay ---
193
+ overlay = image_np.copy().astype(np.float32)
194
+ for cid in dead_cells:
195
+ overlay[masks == cid] = [255, 0, 0]
196
+ for cid in alive_cells:
197
+ overlay[masks == cid] = [0, 255, 0]
198
 
199
+ alpha = 0.4
 
200
  final_overlay = (1 - alpha) * image_np.astype(np.float32) + alpha * overlay
201
  final_overlay = np.clip(final_overlay, 0, 255).astype(np.uint8)
202
 
203
+ return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
204
 
205
 
206
  def measure_confluency(masks, image_np):
 
265
  return int(round(np.percentile(sizes, q)))
266
 
267
 
268
+ def apply_polygon_mask(image_pil, points_json):
269
+ """
270
+ Given a PIL image and a JSON string of [[x,y],...] canvas-space points
271
+ (already mapped to original image coordinates by the JS), zero out
272
+ everything outside the polygon and return a PIL image.
273
+ Returns the original image if points_json is empty / invalid.
274
+ """
275
+ import json
276
+ if not points_json or points_json.strip() in ("", "[]"):
277
+ return image_pil
278
+
279
+ try:
280
+ pts = json.loads(points_json)
281
+ except Exception:
282
+ return image_pil
283
+
284
+ if len(pts) < 3:
285
+ return image_pil
286
+
287
+ image_np = np.array(image_pil)
288
+ h, w = image_np.shape[:2]
289
+
290
+ poly = np.array(pts, dtype=np.int32)
291
+ poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
292
+ poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
293
+
294
+ mask = np.zeros((h, w), dtype=np.uint8)
295
+ cv2.fillPoly(mask, [poly], 255)
296
+
297
+ if len(image_np.shape) == 3:
298
+ mask3 = mask[:, :, np.newaxis]
299
+ result = np.where(mask3 == 255, image_np, 0).astype(np.uint8)
300
+ else:
301
+ result = np.where(mask == 255, image_np, 0).astype(np.uint8)
302
+
303
+ return Image.fromarray(result)
304
+
305
+
306
  def toggle_stereological_mode(use_stereology):
307
  """Show/hide stereological controls based on checkbox"""
308
  return gr.update(visible=use_stereology)
 
318
  return Image.fromarray(overlay)
319
 
320
 
321
+ # ---------------------------------------------------------------------------
322
+ # Patch-parallel segmentation helpers (Option A)
323
+ # ---------------------------------------------------------------------------
324
+ PATCH_SIZE = 512 # target patch side length
325
+ PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
326
+ MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
327
+
328
+
329
+ def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
330
+ """
331
+ Split image into overlapping patches.
332
+ Returns list of (patch_np, row_start, col_start) tuples.
333
+ """
334
+ h, w = image_np.shape[:2]
335
+ patches = []
336
+ row = 0
337
+ while row < h:
338
+ row_end = min(row + patch_size, h)
339
+ col = 0
340
+ while col < w:
341
+ col_end = min(col + patch_size, w)
342
+ patch = image_np[row:row_end, col:col_end]
343
+ patches.append((patch, row, col))
344
+ if col_end == w:
345
+ break
346
+ col += patch_size - overlap
347
+ if row_end == h:
348
+ break
349
+ row += patch_size - overlap
350
+ return patches
351
+
352
+
353
+ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
354
+ """
355
+ Stitch per-patch masks into a single full-image mask.
356
+
357
+ Strategy:
358
+ - Each patch gets a unique ID offset so cell IDs never collide.
359
+ - Patches are pasted into the canvas using a priority canvas that
360
+ gives interior pixels precedence over overlap-border pixels.
361
+ - After pasting, cells whose centroids fall in the overlap zone
362
+ of two adjacent patches are deduplicated: if two cells from
363
+ different patches share >50% IoU they are the same cell β€” keep
364
+ the one whose centroid is furthest from a patch edge.
365
+ """
366
+ full_mask = np.zeros((full_h, full_w), dtype=np.int32)
367
+ # track which patch_idx owns each pixel (used for overlap resolution)
368
+ owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
369
+ # distance-to-nearest-edge for the owning patch (higher = more central)
370
+ priority = np.zeros((full_h, full_w), dtype=np.float32)
371
+
372
+ id_offset = 0
373
+ patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
374
+
375
+ for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
376
+ ph, pw = mask_patch.shape
377
+ # offset all non-zero IDs so they're globally unique
378
+ shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
379
+
380
+ # compute per-pixel priority = min distance to any patch edge
381
+ rows_idx = np.arange(ph)
382
+ cols_idx = np.arange(pw)
383
+ dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
384
+ dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
385
+ pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
386
+
387
+ roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
388
+ roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
389
+ roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
390
+
391
+ # where this patch has higher priority, overwrite
392
+ better = pri_patch > roi_pri
393
+ roi_full [better] = shifted [better]
394
+ roi_owner[better] = patch_idx
395
+ roi_pri [better] = pri_patch [better]
396
+
397
+ max_id = int(mask_patch.max())
398
+ patch_meta.append((id_offset, row_start, col_start, ph, pw))
399
+ id_offset += max_id + 1
400
+
401
+ # --- Renumber to compact sequential IDs ---
402
+ unique_ids = np.unique(full_mask)
403
+ unique_ids = unique_ids[unique_ids > 0]
404
+ renumbered = np.zeros_like(full_mask)
405
+ for new_id, old_id in enumerate(unique_ids, start=1):
406
+ renumbered[full_mask == old_id] = new_id
407
+
408
+ return renumbered
409
+
410
+
411
+ def _segment_patch(args):
412
+ """Worker: run cellpose on a single patch. Called from a thread pool."""
413
+ patch_np, row_start, col_start, model_filename, hf_repo = args
414
+ # Each thread uses the shared loaded_models cache (GIL-safe for reads;
415
+ # model.eval() releases the GIL during GPU work so threads overlap.)
416
+ model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
417
+ if model_filename in loaded_models:
418
+ model = loaded_models[model_filename]
419
+ else:
420
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
421
+ loaded_models[model_filename] = model
422
+
423
+ mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
424
+ return mask, row_start, col_start
425
+
426
+
427
+ def run_segmentation_patched(image_np, model_filename):
428
+ """
429
+ Split image into overlapping patches, run Cellpose on each in parallel,
430
+ then stitch back into a single full-resolution mask.
431
+ Falls back to whole-image segmentation if the image is small enough
432
+ that patching adds overhead without benefit.
433
+ """
434
+ h, w = image_np.shape[:2]
435
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
436
+ if model_filename in loaded_models:
437
+ model = loaded_models[model_filename]
438
+ else:
439
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
440
+ loaded_models[model_filename] = model
441
+
442
+ # Small images: no benefit from patching
443
+ if max(h, w) <= MIN_PATCH_DIM * 2:
444
+ mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
445
+ return mask, 1 # 1 patch
446
+
447
+ patches = _split_patches(image_np)
448
+ n_patches = len(patches)
449
+
450
+ # Build argument list for the thread pool
451
+ args_list = [
452
+ (patch, r, c, model_filename, HF_REPO_ID)
453
+ for patch, r, c in patches
454
+ ]
455
+
456
+ patch_results = [] # (mask, row_start, col_start) in submission order
457
+
458
+ # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
459
+ with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
460
+ futures = {pool.submit(_segment_patch, a): a for a in args_list}
461
+ for future in as_completed(futures):
462
+ mask_patch, row_start, col_start = future.result()
463
+ patch_results.append((mask_patch, row_start, col_start))
464
+
465
+ # Re-sort by (row, col) so stitching is deterministic
466
+ patch_results.sort(key=lambda x: (x[1], x[2]))
467
+
468
+ full_mask = _merge_patch_masks(patch_results, h, w)
469
+ return full_mask, n_patches
470
+
471
+
472
  @spaces.GPU
473
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
474
+ use_stereology, left_exclusion, top_exclusion,
475
+ crop_points_json=""):
476
  image_np = np.array(image)
477
  image_np = safe_resize(image_np)
478
+
479
+ # Apply polygon crop mask if the user drew one
480
+ if crop_points_json and crop_points_json.strip() not in ("", "[]"):
481
+ image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), crop_points_json)
482
+ image_np = np.array(image_pil_masked)
483
 
484
  try:
485
  model_filename = MODEL_OPTIONS[model_choice]
 
 
 
 
 
 
 
486
 
487
  # Process image format to RGB
488
  if len(image_np.shape) == 2:
 
492
  else:
493
  processed_image_np = image_np
494
 
495
+ # Run patch-parallel Cellpose segmentation
496
+ masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
497
 
498
  ids = np.unique(masks_raw)
499
  ids = ids[ids > 0]
 
541
  cell_count = len(np.unique(masks)) - 1
542
  confluency = measure_confluency(masks, processed_image_np)
543
 
544
+ # Create a basic segmentation overlay (without viability)
545
  segmentation_overlay = processed_image_np.copy().astype(np.float32)
546
  if masks.max() > 0:
547
+ np.random.seed(42) # For consistent random colors
548
  colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
549
  colors[0] = [0, 0, 0]
550
  colored_mask = colors[masks]
 
561
  info_msg += filter_msg
562
  info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
563
  info_msg += f"Confluency: {confluency:.1f}%\n"
564
+ info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
565
  if use_stereology:
566
  info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
567
  info_msg += "Now adjust the Blue Threshold for viability assessment."
 
592
  )
593
 
594
 
595
+ def update_viability_realtime(threshold_bias, stored_masks, stored_image_np):
596
  # avoid unpacking None (e.g. slider moved before segmentation)
597
  if stored_masks is None or stored_image_np is None:
598
  return None, 0, 0, 0.0, "Please run segmentation first."
599
 
600
+ stored_masks = unpack_array(stored_masks)
601
  stored_image_np = unpack_array(stored_image_np)
602
 
603
  try:
604
+ dead_count, alive_count, viability_overlay_np, otsu_thresh, final_thresh = \
605
+ classify_cells_by_blueness(stored_image_np, stored_masks, threshold_bias)
 
606
 
607
+ total_count = alive_count + dead_count
608
  viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
609
+ confluency = measure_confluency(stored_masks, stored_image_np)
610
 
611
  overlay_image = Image.fromarray(viability_overlay_np)
612
+ info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
613
+ info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\n"
614
+ info_msg += f"Otsu threshold (auto): {otsu_thresh:.4f}\n"
615
+ info_msg += f"Final threshold (with bias): {final_thresh:.4f}\n"
616
+ info_msg += f"Sensitivity bias: {threshold_bias:+d}"
617
 
618
  return overlay_image, alive_count, dead_count, viability_percent, info_msg
619
 
 
669
  return avg_count, avg_conf, avg_viab, "\n".join(lines)
670
 
671
 
672
+ # ---------------------------------------------------------------------------
673
+ # Tab builder β€” creates one full analysis tab and wires all event handlers.
674
+ # Returns the per-tab gr.State used by Tab 5 to collect summary data.
675
+ # ---------------------------------------------------------------------------
676
+ # ---------------------------------------------------------------------------
677
+ # Polygon crop canvas β€” lightweight HTML5 canvas, no ImageEditor
678
+ # ---------------------------------------------------------------------------
679
+ def make_crop_canvas_html(tab_idx):
680
+ """
681
+ Return an HTML string containing a canvas-based polygon selector.
682
+ The user clicks/taps up to 4 points; JS writes pixel coords (mapped
683
+ back to original image space) into a hidden textbox with id
684
+ f'crop_pts_{tab_idx}'. A 'Clear' button resets the polygon.
685
+ The canvas listens for the Gradio img_input change event by watching
686
+ a sibling img element (Gradio renders <img> inside the component).
687
+ """
688
+ uid = f"cropcanvas_{tab_idx}"
689
+ pts_id = f"crop_pts_{tab_idx}"
690
+ return f"""
691
+ <div style="position:relative;width:100%;user-select:none;">
692
+ <canvas id="{uid}"
693
+ style="width:100%;max-height:400px;object-fit:contain;
694
+ border:2px solid #4a90d9;border-radius:8px;
695
+ touch-action:none;cursor:crosshair;background:#111;display:block;">
696
+ </canvas>
697
+ <div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
698
+ <button id="{uid}_clear"
699
+ style="padding:6px 18px;border-radius:6px;border:none;
700
+ background:#e05;color:#fff;font-size:14px;cursor:pointer;">
701
+ βœ• Clear points
702
+ </button>
703
+ <span id="{uid}_label"
704
+ style="font-size:13px;color:#aaa;">
705
+ Upload an image, then tap up to 4 vertices
706
+ </span>
707
+ </div>
708
+ </div>
709
+
710
+ <script>
711
+ (function(){{
712
+ const CANVAS_ID = "{uid}";
713
+ const PTS_ID = "{pts_id}";
714
+ const TAB_IDX = {tab_idx};
715
+ const MAX_PTS = 4;
716
+
717
+ let imgNaturalW = 0, imgNaturalH = 0;
718
+ let points = []; // {{cx, cy}} in canvas CSS pixels
719
+ let imgSrc = null;
720
+ let imgObj = new Image();
721
+
722
+ const canvas = document.getElementById(CANVAS_ID);
723
+ const ctx = canvas.getContext("2d");
724
+ const clearBtn= document.getElementById(CANVAS_ID + "_clear");
725
+ const label = document.getElementById(CANVAS_ID + "_label");
726
+
727
+ // ── helpers ──────────────────────────────────────────────────────────────
728
+ function getPtsTextbox() {{
729
+ // Gradio hides the real <textarea> inside a wrapper. Find by elem id.
730
+ return document.getElementById(PTS_ID)?.querySelector("textarea")
731
+ || document.querySelector(`#${PTS_ID} textarea`);
732
+ }}
733
+
734
+ function writePts(pts_orig) {{
735
+ const ta = getPtsTextbox();
736
+ if (!ta) return;
737
+ ta.value = JSON.stringify(pts_orig);
738
+ ta.dispatchEvent(new Event("input", {{bubbles:true}}));
739
+ ta.dispatchEvent(new Event("change", {{bubbles:true}}));
740
+ }}
741
+
742
+ function canvasToOriginal(cx, cy) {{
743
+ // canvas.width / height are the backing-store pixel dimensions
744
+ const scaleX = imgNaturalW / canvas.width;
745
+ const scaleY = imgNaturalH / canvas.height;
746
+ return [Math.round(cx * scaleX), Math.round(cy * scaleY)];
747
+ }}
748
+
749
+ function draw() {{
750
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
751
+ if (imgObj.complete && imgObj.naturalWidth) {{
752
+ ctx.drawImage(imgObj, 0, 0, canvas.width, canvas.height);
753
+ }}
754
+ if (points.length === 0) return;
755
+
756
+ // polygon fill
757
+ ctx.beginPath();
758
+ ctx.moveTo(points[0].cx, points[0].cy);
759
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].cx, points[i].cy);
760
+ if (points.length === MAX_PTS) ctx.closePath();
761
+ ctx.strokeStyle = "#4af";
762
+ ctx.lineWidth = 2;
763
+ ctx.stroke();
764
+ if (points.length === MAX_PTS) {{
765
+ ctx.fillStyle = "rgba(74,170,255,0.18)";
766
+ ctx.fill();
767
+ }}
768
+
769
+ // vertex dots + numbers
770
+ points.forEach((p, i) => {{
771
+ ctx.beginPath();
772
+ ctx.arc(p.cx, p.cy, 7, 0, Math.PI*2);
773
+ ctx.fillStyle = "#4af";
774
+ ctx.fill();
775
+ ctx.strokeStyle = "#fff";
776
+ ctx.lineWidth = 1.5;
777
+ ctx.stroke();
778
+ ctx.fillStyle = "#fff";
779
+ ctx.font = "bold 11px sans-serif";
780
+ ctx.textAlign = "center";
781
+ ctx.textBaseline = "middle";
782
+ ctx.fillText(i+1, p.cx, p.cy);
783
+ }});
784
+ }}
785
+
786
+ function loadImage(src) {{
787
+ imgSrc = src;
788
+ imgObj = new Image();
789
+ imgObj.onload = function() {{
790
+ imgNaturalW = imgObj.naturalWidth;
791
+ imgNaturalH = imgObj.naturalHeight;
792
+ // size canvas backing store to match natural image (capped for memory)
793
+ const maxSide = 1024;
794
+ let cw = imgNaturalW, ch = imgNaturalH;
795
+ if (Math.max(cw,ch) > maxSide) {{
796
+ const s = maxSide / Math.max(cw,ch);
797
+ cw = Math.round(cw*s); ch = Math.round(ch*s);
798
+ }}
799
+ canvas.width = cw;
800
+ canvas.height = ch;
801
+ points = [];
802
+ writePts([]);
803
+ label.textContent = "Tap up to 4 vertices to define your crop region";
804
+ draw();
805
+ }};
806
+ imgObj.src = src;
807
+ }}
808
+
809
+ // ── pointer events (works for mouse + touch) ──────────────────────────
810
+ canvas.addEventListener("pointerdown", function(e) {{
811
+ e.preventDefault();
812
+ if (!imgNaturalW) return;
813
+ if (points.length >= MAX_PTS) return;
814
+
815
+ const rect = canvas.getBoundingClientRect();
816
+ const cssX = e.clientX - rect.left;
817
+ const cssY = e.clientY - rect.top;
818
+ // map CSS pixels β†’ backing-store pixels
819
+ const bx = cssX * (canvas.width / rect.width);
820
+ const by = cssY * (canvas.height / rect.height);
821
+
822
+ points.push({{cx: bx, cy: by}});
823
+ draw();
824
+
825
+ // write original-image coords to the hidden textbox
826
+ const orig = points.map(p => canvasToOriginal(p.cx, p.cy));
827
+ writePts(orig);
828
+
829
+ if (points.length === MAX_PTS) {{
830
+ label.textContent = "4 points set β€” clear to redraw";
831
+ }} else {{
832
+ label.textContent = `${{points.length}} / ${{MAX_PTS}} points`;
833
+ }}
834
+ }});
835
+
836
+ clearBtn.addEventListener("click", function() {{
837
+ points = [];
838
+ writePts([]);
839
+ label.textContent = "Points cleared β€” tap to add new vertices";
840
+ draw();
841
+ }});
842
+
843
+ // ── watch for Gradio image uploads ────────────────────────────────────
844
+ // Gradio renders the uploaded image as an <img> inside the component.
845
+ // We poll for it until found, then watch src changes with MutationObserver.
846
+ function findGradioImg() {{
847
+ // Gradio wraps img_input in a div whose data-testid = "image"
848
+ // Walk up from our canvas to find the nearest Gradio Blocks row,
849
+ // then find the image component in the same Tab.
850
+ const allImgs = document.querySelectorAll(".gradio-image img, [data-testid='image'] img");
851
+ // pick the one closest to our canvas in the DOM
852
+ if (allImgs.length === 0) return null;
853
+ // heuristic: find closest ancestor shared with canvas
854
+ for (const img of allImgs) {{
855
+ if (img.src && img.src !== imgSrc) return img;
856
+ }}
857
+ return allImgs[0];
858
+ }}
859
+
860
+ let observer = null;
861
+ function startWatching() {{
862
+ const img = findGradioImg();
863
+ if (!img) {{ setTimeout(startWatching, 500); return; }}
864
+ if (img.src && img.src !== imgSrc) loadImage(img.src);
865
+ if (observer) observer.disconnect();
866
+ observer = new MutationObserver(() => {{
867
+ if (img.src && img.src !== imgSrc) loadImage(img.src);
868
+ }});
869
+ observer.observe(img, {{attributes: true, attributeFilter: ["src"]}});
870
+ }}
871
+ setTimeout(startWatching, 800);
872
+ }})();
873
+ </script>
874
+ """
875
+
876
+
877
  def build_tab(tab_index, masks_state, image_state, result_state):
878
  with gr.Tab(f"Tab {tab_index}"):
879
  gr.Markdown("Run segmentation")
 
882
  with gr.Column():
883
  img_input = gr.Image(
884
  type="pil",
885
+ label="Upload image",
886
  image_mode="RGB",
887
  height=512
888
  )
889
 
890
+ # Lightweight polygon crop selector (no ImageEditor)
891
+ gr.HTML(make_crop_canvas_html(tab_index))
892
+
893
+ # Hidden textbox that receives [[x,y],...] from JS
894
+ crop_points = gr.Textbox(
895
+ value="",
896
+ visible=False,
897
+ elem_id=f"crop_pts_{tab_index}",
898
+ label="Crop polygon points (set by canvas)"
899
+ )
900
+
901
  model_dropdown = gr.Dropdown(
902
  choices=list(MODEL_OPTIONS.keys()),
903
  label="Select Model",
 
969
 
970
  with gr.Group(visible=False) as viability_section:
971
  gr.Markdown("### Viability Assessment (Trypan Blue)")
972
+ gr.Markdown(
973
+ "The threshold is set **automatically per image** using Otsu's method. "
974
+ "Use the sensitivity slider to fine-tune if needed."
975
+ )
976
 
977
  with gr.Row():
978
  with gr.Column():
979
+ sensitivity = gr.Slider(
980
+ minimum=-50,
981
+ maximum=50,
982
+ value=0,
983
  step=1,
984
+ label="Sensitivity",
985
+ info="← more dead cells Β· auto (0) Β· fewer dead cells β†’"
986
  )
987
  with gr.Column():
988
  live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
 
990
 
991
  viab_overlay = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
992
  viab_percent_out = gr.Number(label="Viability (%)", precision=1)
993
+ viab_info = gr.Textbox(label="Analysis Results", lines=6)
994
 
995
+ # ---- Event handlers ------------------------------------------------
996
 
997
  use_stereo.change(
998
  fn=toggle_stereological_mode,
 
1019
  segment_btn.click(
1020
  fn=run_segmentation,
1021
  inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1022
+ use_stereo, left_excl, top_excl, crop_points],
1023
  outputs=[cell_count_out, overlay_out, info_out, viability_section,
1024
  masks_state, image_state, confluency_out, min_size_slider]
1025
  ).then(
1026
  fn=update_viability_realtime,
1027
+ inputs=[sensitivity, masks_state, image_state],
1028
  outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
1029
  ).then(
 
1030
  fn=save_tab_result,
1031
  inputs=[cell_count_out, confluency_out, viab_percent_out],
1032
  outputs=[result_state]
1033
  )
1034
 
1035
+ sensitivity.change(
1036
  fn=update_viability_realtime,
1037
+ inputs=[sensitivity, masks_state, image_state],
1038
  outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
1039
  ).then(
 
1040
  fn=save_tab_result,
1041
  inputs=[cell_count_out, confluency_out, viab_percent_out],
1042
  outputs=[result_state]
1043
  )
1044
 
1045
 
1046
+ # ---------------------------------------------------------------------------
1047
+ # Gradio interface
1048
+ # ---------------------------------------------------------------------------
1049
  with gr.Blocks(
1050
  title="CellposeCellCounter",
1051
  theme=gr.themes.Soft(),
 
1062
  for i in range(4):
1063
  build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1064
 
1065
+ # -------------------------------------------------------------------------
1066
+ # Tab 5 β€” Summary
1067
+ # -------------------------------------------------------------------------
1068
  with gr.Tab("Tab 5 β€” Summary"):
1069
  gr.Markdown("## Average Results Across All Tabs")
1070
  gr.Markdown(
 
1072
  "then click **Refresh Summary** to see the averages."
1073
  )
1074
 
1075
+ refresh_btn = gr.Button("πŸ”„ Refresh Summary", variant="primary", size="lg")
1076
 
1077
  with gr.Row():
1078
  avg_count_out = gr.Number(label="Avg Cell Count", precision=1)