LiangLabUMB commited on
Commit
c7a83e0
Β·
verified Β·
1 Parent(s): b50c3b9

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1726 -0
app.py ADDED
@@ -0,0 +1,1726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import spaces
3
+ from cellpose import models
4
+ import numpy as np
5
+ import cv2
6
+ import matplotlib.pyplot as plt
7
+ import tempfile
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
+ import csv
14
+ import joblib
15
+ import os
16
+
17
+ HF_REPO_ID = "myang4218/cellposemodel"
18
+ HF_REPO_ID2 = "LiangLabUMB/viability_model"
19
+ MODEL_OPTIONS = {
20
+ "Hemocytometer Model": "hemocytometermodel.npy",
21
+ "General Model": "generalmodel.npy"
22
+ }
23
+
24
+ loaded_models = {}
25
+
26
+ VIABILITY_CLF = None
27
+ VIABILITY_SCALER = None
28
+
29
+ try:
30
+ _clf_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_clf.pkl")
31
+ _scaler_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_scaler.pkl")
32
+ VIABILITY_CLF = joblib.load(_clf_path)
33
+ VIABILITY_SCALER = joblib.load(_scaler_path)
34
+ print("βœ“ Viability classifier loaded.")
35
+ except Exception as e:
36
+ print(f"Viability classifier not found or failed to load: {e}")
37
+
38
+ # ---- mobile-safe size limits (aggressive for Safari) ----
39
+ MAX_SIDE = 1024
40
+ MAX_PIXELS = 1024 * 1024
41
+
42
+
43
+ def safe_resize(image_np):
44
+ """
45
+ Downscale image to fit within MAX_SIDE and MAX_PIXELS while
46
+ preserving aspect ratio. Works for RGB / RGBA / grayscale.
47
+ """
48
+ h, w = image_np.shape[:2]
49
+ total = h * w
50
+
51
+ if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
52
+ return image_np
53
+
54
+ # compute scale
55
+ scale_side = MAX_SIDE / max(h, w)
56
+ scale_pixels = (MAX_PIXELS / total) ** 0.5
57
+ scale = min(scale_side, scale_pixels)
58
+
59
+ new_w = max(1, int(w * scale))
60
+ new_h = max(1, int(h * scale))
61
+
62
+ return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
63
+
64
+
65
+ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
66
+
67
+ h, w = image_np.shape[:2]
68
+
69
+ # Convert to PIL for drawing
70
+ img_pil = Image.fromarray(image_np)
71
+ draw = ImageDraw.Draw(img_pil, 'RGBA')
72
+
73
+ # Calculate pixel widths from percentages
74
+ left_px = int(w * left_width_pct / 100)
75
+ top_px = int(h * top_width_pct / 100)
76
+
77
+ # Draw overlays for exclusion zones
78
+ if left_px > 0:
79
+ # Left exclusion zone
80
+ draw.rectangle(
81
+ [(0, 0), (left_px, h)],
82
+ fill=(255, 0, 0, 80) # Semi-transparent red
83
+ )
84
+ # border line
85
+ draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
86
+
87
+ if top_px > 0:
88
+ # Top exclusion zone
89
+ draw.rectangle(
90
+ [(0, 0), (w, top_px)],
91
+ fill=(255, 0, 0, 80) # Semi-transparent red
92
+ )
93
+ # border line
94
+ draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
95
+
96
+ return np.array(img_pil)
97
+
98
+
99
+ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
100
+ """
101
+ Exclude every cell touching the left or top exclusion zone.
102
+
103
+ A cell intersects the half-plane x < left_px exactly when its leftmost
104
+ pixel does, so per-label bounding boxes give an exact answer -- no need to
105
+ approximate with centroids and radii. scipy's find_objects collects every
106
+ bounding box in a single pass, instead of one full-image comparison per
107
+ cell, which is what makes this cheap enough to re-run interactively.
108
+
109
+ Cell ids are NOT renumbered: stable ids let the exclusion be re-applied
110
+ after viability classification without invalidating its label map.
111
+ """
112
+ from scipy import ndimage
113
+
114
+ h, w = masks.shape
115
+ left_px = int(w * left_width_pct / 100)
116
+ top_px = int(h * top_width_pct / 100)
117
+
118
+ if left_px <= 0 and top_px <= 0:
119
+ n = len(np.unique(masks)) - (1 if (masks == 0).any() else 0)
120
+ return masks.copy(), 0, n
121
+
122
+ boxes = ndimage.find_objects(masks)
123
+ excluded_ids = []
124
+ included_ids = []
125
+ for idx, box in enumerate(boxes):
126
+ if box is None: # id absent from the label image
127
+ continue
128
+ cell_id = idx + 1
129
+ row_slice, col_slice = box
130
+ touches_left = left_px > 0 and col_slice.start < left_px
131
+ touches_top = top_px > 0 and row_slice.start < top_px
132
+ if touches_left or touches_top:
133
+ excluded_ids.append(cell_id)
134
+ else:
135
+ included_ids.append(cell_id)
136
+
137
+ filtered_masks = masks.copy()
138
+ if excluded_ids:
139
+ drop = np.zeros(int(masks.max()) + 1, dtype=bool)
140
+ drop[np.asarray(excluded_ids, dtype=np.int64)] = True
141
+ filtered_masks[drop[masks]] = 0
142
+
143
+ return filtered_masks, len(excluded_ids), len(included_ids)
144
+
145
+
146
+ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
147
+
148
+ h, w = image_np.shape[:2]
149
+
150
+ # Convert to PIL for drawing
151
+ img_pil = Image.fromarray(image_np)
152
+ draw = ImageDraw.Draw(img_pil, 'RGBA')
153
+
154
+ # Calculate pixel widths from percentages
155
+ left_px = int(w * left_width_pct / 100)
156
+ top_px = int(h * top_width_pct / 100)
157
+
158
+ # Draw overlays for exclusion zones
159
+ if left_px > 0:
160
+ # Left exclusion zone
161
+ draw.rectangle(
162
+ [(0, 0), (left_px, h)],
163
+ fill=(255, 0, 0, 80) # Semi-transparent red
164
+ )
165
+ # border line
166
+ draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
167
+
168
+ if top_px > 0:
169
+ # Top exclusion zone
170
+ draw.rectangle(
171
+ [(0, 0), (w, top_px)],
172
+ fill=(255, 0, 0, 80) # Semi-transparent red
173
+ )
174
+ # border line
175
+ draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
176
+
177
+ return np.array(img_pil)
178
+
179
+
180
+ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
181
+ h, w = masks.shape
182
+
183
+ # Calculate pixel widths from percentages
184
+ left_px = int(w * left_width_pct / 100)
185
+ top_px = int(h * top_width_pct / 100)
186
+
187
+ filtered_masks = masks.copy()
188
+ cell_ids = np.unique(masks)
189
+ cell_ids = cell_ids[cell_ids > 0]
190
+
191
+ excluded_cells = []
192
+ included_cells = []
193
+
194
+ for cell_id in cell_ids:
195
+ cell_mask = (masks == cell_id)
196
+
197
+ # Get cell boundary coordinates
198
+ rows, cols = np.where(cell_mask)
199
+
200
+ # Check if cell touches left exclusion zone
201
+ touches_left = np.any(cols < left_px) if left_px > 0 else False
202
+
203
+ # Check if cell touches top exclusion zone
204
+ touches_top = np.any(rows < top_px) if top_px > 0 else False
205
+
206
+ # Exclude if touching left or top
207
+ if touches_left or touches_top:
208
+ filtered_masks[cell_mask] = 0
209
+ excluded_cells.append(cell_id)
210
+ else:
211
+ included_cells.append(cell_id)
212
+
213
+ # Renumber remaining cells
214
+ unique_ids = np.unique(filtered_masks)
215
+ unique_ids = unique_ids[unique_ids > 0]
216
+
217
+ renumbered_masks = np.zeros_like(filtered_masks)
218
+ for new_id, old_id in enumerate(unique_ids, start=1):
219
+ renumbered_masks[filtered_masks == old_id] = new_id
220
+
221
+ return renumbered_masks, len(excluded_cells), len(included_cells)
222
+
223
+
224
+
225
+ FEATURE_COLS_INFERENCE = [
226
+ "mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b",
227
+ "mean_h", "mean_s", "mean_v", "std_s", "std_v",
228
+ "blue_red_ratio", "blue_green_ratio", "rg_ratio",
229
+ "inner_brightness", "peak_brightness",
230
+ "bright_spot_fraction", "ring_darkness",
231
+ "centre_periphery_ratio", "brightness_std_normalised",
232
+ ]
233
+
234
+
235
+ def classify_cells_by_model(image_np, masks):
236
+ """
237
+ Run the trained LogisticRegression classifier to predict live/dead per cell.
238
+ Returns (dead_count, alive_count, overlay_np, {cell_id: label}).
239
+ Requires VIABILITY_CLF and VIABILITY_SCALER to be loaded.
240
+ """
241
+ import numpy as np
242
+ cell_ids = np.unique(masks)
243
+ cell_ids = cell_ids[cell_ids > 0]
244
+ if len(cell_ids) == 0:
245
+ return 0, 0, image_np.copy(), {}
246
+
247
+ features = extract_cell_features(image_np, masks)
248
+ if not features:
249
+ return 0, 0, image_np.copy(), {}
250
+
251
+ import numpy as np
252
+ X = np.array([[f[c] for c in FEATURE_COLS_INFERENCE] for f in features], dtype=np.float32)
253
+
254
+ # replace any NaN/Inf with column median
255
+ for j in range(X.shape[1]):
256
+ bad = ~np.isfinite(X[:, j])
257
+ if bad.any():
258
+ X[bad, j] = float(np.nanmedian(X[:, j]))
259
+
260
+ X_scaled = VIABILITY_SCALER.transform(X)
261
+ predictions = VIABILITY_CLF.predict(X_scaled) # 0=live, 1=dead
262
+
263
+ label_map = {int(f["cell_id"]): int(p) for f, p in zip(features, predictions)}
264
+ overlay = draw_viability_overlay(image_np, masks, label_map)
265
+
266
+ dead = int(sum(predictions))
267
+ alive = int(len(predictions) - dead)
268
+ return dead, alive, overlay, label_map
269
+
270
+
271
+ def draw_viability_overlay(image_np, masks, label_map):
272
+ """
273
+ Draw coloured cell outlines onto image_np: green = live, red = dead.
274
+ label_map: {cell_id: 0=live, 1=dead}
275
+ Returns a uint8 numpy array.
276
+ """
277
+ overlay = image_np.copy()
278
+ cell_ids = np.unique(masks)
279
+ cell_ids = cell_ids[cell_ids > 0]
280
+
281
+ for cid in cell_ids:
282
+ label = label_map.get(int(cid), 0)
283
+ color = (220, 50, 50) if label == 1 else (50, 220, 80)
284
+ cell_mask = (masks == cid).astype(np.uint8)
285
+ contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
286
+ cv2.drawContours(overlay, contours, -1, color, thickness=2)
287
+
288
+ return overlay
289
+
290
+
291
+ def classify_cells_by_blueness(image_np, masks, threshold_bias):
292
+ """
293
+ Classify cells as dead (blue) or alive using an adaptive Otsu threshold
294
+ on per-cell blueness scores, with a user bias to fine-tune.
295
+
296
+ Args:
297
+ image_np: RGB image array
298
+ masks: Cellpose segmentation masks
299
+ threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
300
+ Negative = more cells classified dead (looser).
301
+ Positive = fewer cells classified dead (stricter).
302
+ 0 = pure Otsu (fully automatic).
303
+
304
+ Returns:
305
+ dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
306
+ """
307
+
308
+ if len(image_np.shape) == 2:
309
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
310
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
311
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
312
+
313
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
314
+
315
+ hue = hsv[:, :, 0].astype(np.float32)
316
+ saturation = hsv[:, :, 1].astype(np.float32)
317
+
318
+ # Raw blueness: hue proximity to 115Β° Γ— saturation
319
+ hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
320
+ hue_score = np.maximum(0, 1 - hue_distance / 65)
321
+ blueness = hue_score * (saturation / 255.0)
322
+
323
+ # --- Compute per-cell mean blueness scores ---
324
+ cell_ids = np.unique(masks)
325
+ cell_ids = cell_ids[cell_ids > 0]
326
+
327
+ if len(cell_ids) == 0:
328
+ blank = image_np.copy()
329
+ return 0, 0, blank, 0.0, 0.0
330
+
331
+ cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
332
+
333
+ # --- Otsu on the distribution of per-cell scores ---
334
+ # cv2.threshold expects uint8; scale 0-1 β†’ 0-255
335
+ scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
336
+
337
+ if scores_u8.max() == scores_u8.min():
338
+ # All cells identical β†’ Otsu is undefined; use midpoint
339
+ otsu_threshold = float(scores_u8[0]) / 255.0
340
+ else:
341
+ # Reshape to a single-column image so cv2.threshold works
342
+ thresh_val, _ = cv2.threshold(
343
+ scores_u8.reshape(-1, 1), 0, 255,
344
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU
345
+ )
346
+ otsu_threshold = thresh_val / 255.0
347
+
348
+ # --- Apply user bias: slider -50..+50 maps to Β±0.20 shift ---
349
+ bias = (threshold_bias / 50.0) * 0.20
350
+ final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
351
+
352
+ # --- Classify ---
353
+ dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
354
+ alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
355
+
356
+ # --- Outline-only overlay on raw image with enumerated labels ---
357
+ final_overlay = image_np.copy()
358
+
359
+ # Compute a consistent enumeration order (cell_ids is already sorted ascending)
360
+ cell_enum = {cid: idx + 1 for idx, cid in enumerate(cell_ids)}
361
+
362
+ dead_set = set(dead_cells)
363
+ alive_set = set(alive_cells)
364
+
365
+ for cid in cell_ids:
366
+ cell_mask = (masks == cid).astype(np.uint8)
367
+ contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
368
+ color = (220, 50, 50) if cid in dead_set else (50, 220, 80)
369
+ cv2.drawContours(final_overlay, contours, -1, color, thickness=2)
370
+
371
+ # Draw enumeration label at centroid
372
+ ys, xs = np.where(cell_mask)
373
+ if len(ys) > 0:
374
+ cx, cy = int(xs.mean()), int(ys.mean())
375
+ label_str = str(cell_enum[cid])
376
+ font = cv2.FONT_HERSHEY_SIMPLEX
377
+ font_scale = 0.35
378
+ thickness = 1
379
+ (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
380
+ # Dark background rectangle for readability
381
+ cv2.rectangle(
382
+ final_overlay,
383
+ (cx - tw // 2 - 1, cy - th // 2 - 1),
384
+ (cx + tw // 2 + 1, cy + th // 2 + 1),
385
+ (0, 0, 0),
386
+ -1
387
+ )
388
+ cv2.putText(
389
+ final_overlay, label_str,
390
+ (cx - tw // 2, cy + th // 2),
391
+ font, font_scale, color, thickness, cv2.LINE_AA
392
+ )
393
+
394
+ return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
395
+
396
+
397
+ def measure_confluency(masks, image_np):
398
+ tot_pixels = image_np.shape[0] * image_np.shape[1]
399
+ cell_pixels = np.count_nonzero(masks)
400
+ confluency = cell_pixels / tot_pixels * 100
401
+ return confluency
402
+
403
+ def filter_mask_by_size(masks, minimum_pixels):
404
+ filtered_masks = masks.copy()
405
+ cell_ids = np.unique(masks)
406
+ cell_ids = cell_ids[cell_ids > 0]
407
+
408
+ removed_count = 0
409
+
410
+ for cell_id in cell_ids:
411
+ cell_mask = (masks == cell_id)
412
+ cell_pixels = np.count_nonzero(cell_mask)
413
+ if cell_pixels < minimum_pixels:
414
+ filtered_masks[cell_mask] = 0
415
+ removed_count += 1
416
+
417
+ unique_ids = np.unique(filtered_masks)
418
+ unique_ids = unique_ids[unique_ids > 0]
419
+
420
+ renumbered_masks = np.zeros_like(filtered_masks)
421
+ for new_id, old_id in enumerate(unique_ids, start=1):
422
+ renumbered_masks[filtered_masks == old_id] = new_id
423
+
424
+ return renumbered_masks, removed_count
425
+
426
+
427
+ def filter_mask_by_maxsize(masks, maximum_pixels):
428
+ filtered_masks = masks.copy()
429
+ cell_ids = np.unique(masks)
430
+ cell_ids = cell_ids[cell_ids > 0]
431
+
432
+ removed_count = 0
433
+ for cell_id in cell_ids:
434
+ cell_mask = (masks == cell_id)
435
+ cell_pixels = np.count_nonzero(cell_mask)
436
+ if cell_pixels > maximum_pixels:
437
+ filtered_masks[cell_mask] = 0
438
+ removed_count += 1
439
+
440
+ unique_ids = np.unique(filtered_masks)
441
+ unique_ids = unique_ids[unique_ids > 0]
442
+
443
+ renumbered_masks = np.zeros_like(filtered_masks)
444
+ for new_id, old_id in enumerate(unique_ids, start=1):
445
+ renumbered_masks[filtered_masks == old_id] = new_id
446
+
447
+ return renumbered_masks, removed_count
448
+
449
+
450
+ def rec_min_size(masks, q=25):
451
+ ids = np.unique(masks)
452
+ ids = ids[ids > 0]
453
+ if len(ids) == 0:
454
+ return 0
455
+ sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
456
+ return int(round(np.percentile(sizes, q)))
457
+
458
+
459
+ def apply_polygon_mask(image_pil, points_json):
460
+ """
461
+ Given a PIL image and a JSON string of [[x,y],...] points,
462
+ zero out everything outside the polygon and return a PIL image.
463
+ """
464
+ import json
465
+ if not points_json or points_json.strip() in ("", "[]"):
466
+ return image_pil
467
+ try:
468
+ pts = json.loads(points_json)
469
+ except Exception:
470
+ return image_pil
471
+ if len(pts) < 3:
472
+ return image_pil
473
+
474
+ image_np = np.array(image_pil)
475
+ h, w = image_np.shape[:2]
476
+ poly = np.array(pts, dtype=np.int32)
477
+ poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
478
+ poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
479
+ mask = np.zeros((h, w), dtype=np.uint8)
480
+ cv2.fillPoly(mask, [poly], 255)
481
+ if len(image_np.shape) == 3:
482
+ result = np.where(mask[:, :, np.newaxis] == 255, image_np, 0).astype(np.uint8)
483
+ else:
484
+ result = np.where(mask == 255, image_np, 0).astype(np.uint8)
485
+ return Image.fromarray(result)
486
+
487
+ def order_quad(pts):
488
+ """
489
+ Order 4 points as (top-left, top-right, bottom-right, bottom-left).
490
+ Sorts by angle about the centroid so the result stays correct for
491
+ rotated quads, where the sum/difference heuristic breaks down.
492
+ """
493
+ pts = np.asarray(pts, dtype=np.float32)
494
+ centre = pts.mean(axis=0)
495
+ angles = np.arctan2(pts[:, 1] - centre[1], pts[:, 0] - centre[0])
496
+ # Clockwise on screen (y grows downward) == increasing angle here
497
+ ordered = pts[np.argsort(angles)]
498
+ # Rotate so the corner nearest the top-left of the quad comes first
499
+ start = np.argmin(ordered.sum(axis=1))
500
+ return np.roll(ordered, -start, axis=0)
501
+
502
+
503
+ def quad_output_size(points):
504
+ """
505
+ Width and height, in source pixels, of the rectangle a 4-point quad
506
+ warps to. Shared by the warp itself and the exclusion-zone preview so
507
+ the two can never disagree about the ROI's dimensions.
508
+ """
509
+ tl, tr, br, bl = order_quad(points)
510
+ out_w = max(1, int(round(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))))
511
+ out_h = max(1, int(round(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))))
512
+ return out_w, out_h
513
+
514
+
515
+ def warp_polygon_to_square(image_np, points):
516
+ src = order_quad(points)
517
+ out_w, out_h = quad_output_size(points)
518
+
519
+ dst = np.array(
520
+ [[0, 0],
521
+ [out_w - 1, 0],
522
+ [out_w - 1, out_h - 1],
523
+ [0, out_h - 1]],
524
+ dtype=np.float32)
525
+
526
+ M = cv2.getPerspectiveTransform(src, dst)
527
+ warped = cv2.warpPerspective(image_np, M, (out_w, out_h))
528
+ return warped
529
+
530
+
531
+ def toggle_stereological_mode(use_stereology):
532
+ """Show/hide stereological controls based on checkbox"""
533
+ return gr.update(visible=use_stereology)
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Patch segmentation
538
+ # ---------------------------------------------------------------------------
539
+ PATCH_SIZE = 512 # target patch side length
540
+ PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
541
+ MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
542
+
543
+
544
+ def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
545
+ """
546
+ Split image into overlapping patches.
547
+ Returns list of (patch_np, row_start, col_start) tuples.
548
+ """
549
+ h, w = image_np.shape[:2]
550
+ patches = []
551
+ row = 0
552
+ while row < h:
553
+ row_end = min(row + patch_size, h)
554
+ col = 0
555
+ while col < w:
556
+ col_end = min(col + patch_size, w)
557
+ patch = image_np[row:row_end, col:col_end]
558
+ patches.append((patch, row, col))
559
+ if col_end == w:
560
+ break
561
+ col += patch_size - overlap
562
+ if row_end == h:
563
+ break
564
+ row += patch_size - overlap
565
+ return patches
566
+
567
+
568
+ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
569
+ """
570
+ Stitch per-patch masks into a single full-image mask.
571
+
572
+ Strategy:
573
+ - Each patch gets a unique ID offset so cell IDs never collide.
574
+ - Patches are pasted into the canvas using a priority canvas that
575
+ gives interior pixels precedence over overlap-border pixels.
576
+ - After pasting, cells whose centroids fall in the overlap zone
577
+ of two adjacent patches are deduplicated: if two cells from
578
+ different patches share >50% IoU they are the same cell β€” keep
579
+ the one whose centroid is furthest from a patch edge.
580
+ """
581
+ full_mask = np.zeros((full_h, full_w), dtype=np.int32)
582
+ # track which patch_idx owns each pixel (used for overlap resolution)
583
+ owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
584
+ # distance-to-nearest-edge for the owning patch (higher = more central)
585
+ priority = np.zeros((full_h, full_w), dtype=np.float32)
586
+
587
+ id_offset = 0
588
+ patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
589
+
590
+ for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
591
+ ph, pw = mask_patch.shape
592
+ # offset all non-zero IDs so they're globally unique.
593
+ # Widen BEFORE adding: cellpose returns uint16 masks, and adding the
594
+ # offset in that dtype wraps once ids pass 65535.
595
+ mask_patch = mask_patch.astype(np.int32, copy=False)
596
+ shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
597
+
598
+ # compute per-pixel priority = min distance to any patch edge
599
+ rows_idx = np.arange(ph)
600
+ cols_idx = np.arange(pw)
601
+ dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
602
+ dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
603
+ pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
604
+
605
+ roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
606
+ roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
607
+ roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
608
+
609
+ # where this patch has higher priority, overwrite
610
+ better = pri_patch > roi_pri
611
+ roi_full [better] = shifted [better]
612
+ roi_owner[better] = patch_idx
613
+ roi_pri [better] = pri_patch [better]
614
+
615
+ max_id = int(mask_patch.max())
616
+ patch_meta.append((id_offset, row_start, col_start, ph, pw))
617
+ id_offset += max_id + 1
618
+
619
+ # --- Renumber to compact sequential IDs ---
620
+ unique_ids = np.unique(full_mask)
621
+ unique_ids = unique_ids[unique_ids > 0]
622
+ renumbered = np.zeros_like(full_mask)
623
+ for new_id, old_id in enumerate(unique_ids, start=1):
624
+ renumbered[full_mask == old_id] = new_id
625
+
626
+ return renumbered
627
+
628
+
629
+ def _segment_patch(args):
630
+ """Worker: run cellpose on a single patch. Called from a thread pool."""
631
+ patch_np, row_start, col_start, model_filename, hf_repo = args
632
+ # Each thread uses the shared loaded_models cache (GIL-safe for reads;
633
+ # model.eval() releases the GIL during GPU work so threads overlap.)
634
+ model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
635
+ if model_filename in loaded_models:
636
+ model = loaded_models[model_filename]
637
+ else:
638
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
639
+ loaded_models[model_filename] = model
640
+
641
+ mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
642
+ return mask, row_start, col_start
643
+
644
+
645
+ def run_segmentation_patched(image_np, model_filename):
646
+ """
647
+ Split image into overlapping patches, run Cellpose on each in parallel,
648
+ then stitch back into a single full-resolution mask.
649
+ Falls back to whole-image segmentation if the image is small enough
650
+ that patching adds overhead without benefit.
651
+ """
652
+ h, w = image_np.shape[:2]
653
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
654
+ if model_filename in loaded_models:
655
+ model = loaded_models[model_filename]
656
+ else:
657
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
658
+ loaded_models[model_filename] = model
659
+
660
+ # Small images: no benefit from patching
661
+ if max(h, w) <= MIN_PATCH_DIM * 2:
662
+ mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
663
+ return mask, 1 # 1 patch
664
+
665
+ patches = _split_patches(image_np)
666
+ n_patches = len(patches)
667
+
668
+ # Build argument list for the thread pool
669
+ args_list = [
670
+ (patch, r, c, model_filename, HF_REPO_ID)
671
+ for patch, r, c in patches
672
+ ]
673
+
674
+ patch_results = [] # (mask, row_start, col_start) in submission order
675
+
676
+ # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
677
+ with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
678
+ futures = {pool.submit(_segment_patch, a): a for a in args_list}
679
+ for future in as_completed(futures):
680
+ mask_patch, row_start, col_start = future.result()
681
+ patch_results.append((mask_patch, row_start, col_start))
682
+
683
+ # Re-sort by (row, col) so stitching is deterministic
684
+ patch_results.sort(key=lambda x: (x[1], x[2]))
685
+
686
+ full_mask = _merge_patch_masks(patch_results, h, w)
687
+ return full_mask, n_patches
688
+
689
+
690
+ def render_segmentation(base_masks, processed_image_np,
691
+ use_stereology, left_exclusion, top_exclusion):
692
+ """
693
+ Apply the stereological exclusion to already-segmented masks and rebuild
694
+ the overlay. Split out of run_segmentation so the exclusion zones can be
695
+ adjusted after segmentation without re-running Cellpose.
696
+
697
+ Returns (masks, cell_count, confluency, overlay_pil, excluded_count).
698
+ """
699
+ if use_stereology:
700
+ masks, excluded_count, _ = apply_stereological_exclusion(
701
+ base_masks, left_exclusion, top_exclusion
702
+ )
703
+ else:
704
+ masks, excluded_count = base_masks.copy(), 0
705
+
706
+ cell_count = int(len(np.unique(masks)) - (1 if (masks == 0).any() else 0))
707
+ confluency = measure_confluency(masks, processed_image_np)
708
+
709
+ overlay = processed_image_np.copy().astype(np.float32)
710
+ if masks.max() > 0:
711
+ np.random.seed(42) # For consistent random colors
712
+ colors = np.random.randint(0, 255, size=(int(masks.max()) + 1, 3))
713
+ colors[0] = [0, 0, 0]
714
+ colored_mask = colors[masks]
715
+ alpha = 0.4
716
+ overlay = (1 - alpha) * overlay + alpha * colored_mask
717
+ overlay = np.clip(overlay, 0, 255).astype(np.uint8)
718
+
719
+ if use_stereology:
720
+ overlay = draw_exclusion_overlay(overlay, left_exclusion, top_exclusion)
721
+
722
+ return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
723
+
724
+
725
+ @spaces.GPU
726
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
727
+ use_stereology, left_exclusion, top_exclusion,
728
+ crop_points=None, use_min_filter=False, use_max_filter=False):
729
+ image_np = np.array(image)
730
+
731
+ # Crop BEFORE downscaling, so the ROI is sampled from the original pixels
732
+ # rather than upscaled out of a ≀MAX_SIDE working copy. The crop points were
733
+ # clicked on the full-resolution upload, so they are already in this space.
734
+ # (Need β‰₯3 points for a polygon.)
735
+ if crop_points and len(crop_points) >= 3:
736
+ import json
737
+ if len(crop_points) == 4:
738
+ # The perspective warp already discards everything outside the quad,
739
+ # so masking first would only round the corners off.
740
+ image_np = warp_polygon_to_square(image_np, crop_points)
741
+ else:
742
+ pts_json = json.dumps([[float(x), float(y)] for x, y in crop_points])
743
+ image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
744
+ image_np = np.array(image_pil_masked)
745
+
746
+ # Cap the working image only after cropping β€” a small ROI now keeps its
747
+ # native detail, and a large one is still bounded for segmentation.
748
+ image_np = safe_resize(image_np)
749
+
750
+ # Un-annotated copy of whatever we actually segment, kept for cell thumbnails.
751
+ # Must be taken after the crop so it stays index-compatible with the masks.
752
+ raw_image_np = image_np.copy()
753
+
754
+
755
+ try:
756
+ model_filename = MODEL_OPTIONS[model_choice]
757
+
758
+ # Process image format to RGB
759
+ if len(image_np.shape) == 2:
760
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
761
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
762
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
763
+ else:
764
+ processed_image_np = image_np
765
+
766
+ # Run patch-parallel Cellpose segmentation
767
+ masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
768
+
769
+ ids = np.unique(masks_raw)
770
+ ids = ids[ids > 0]
771
+
772
+ sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
773
+
774
+ print("num_cells:", len(ids))
775
+ print("mean:", sizes.mean() if len(sizes) > 0 else 0)
776
+ print("median:", np.median(sizes) if len(sizes) > 0 else 0)
777
+ print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
778
+ print("max:", sizes.max() if len(sizes) > 0 else 0)
779
+
780
+ # Compute recommendation from RAW masks
781
+ recommend_min = rec_min_size(masks_raw)
782
+
783
+ # Surface the recommendation as text rather than writing it into the
784
+ # slider, which would overwrite a threshold the user had chosen.
785
+ if recommend_min > 0:
786
+ rec_msg = (f"*Recommended minimum: **{recommend_min}** pixels "
787
+ f"(25th percentile of detected object sizes). "
788
+ f"Enable the filter and leave the slider at 0 to use it.*")
789
+ else:
790
+ rec_msg = "*No objects detected β€” no minimum size recommendation.*"
791
+
792
+ # Size filters only run when their checkbox is ticked. When the minimum
793
+ # filter is on but the slider is still at 0, fall back to the
794
+ # recommendation; when it is off, nothing is filtered at all.
795
+ min_used = 0
796
+ if use_min_filter:
797
+ min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
798
+
799
+ masks = masks_raw.copy()
800
+ removed_small = 0
801
+ removed_large = 0
802
+
803
+ if use_min_filter and min_used > 0:
804
+ masks, removed_small = filter_mask_by_size(masks, min_used)
805
+
806
+ if use_max_filter and max_cell_size > 0:
807
+ masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
808
+
809
+ # Masks before exclusion are kept in state so the zones stay adjustable
810
+ base_masks = masks
811
+ masks, cell_count, confluency, overlay_pil, excluded_count = render_segmentation(
812
+ base_masks, processed_image_np, use_stereology, left_exclusion, top_exclusion
813
+ )
814
+
815
+ filter_msg = ""
816
+ if removed_small:
817
+ filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
818
+ if removed_large:
819
+ filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
820
+ if use_stereology and excluded_count > 0:
821
+ filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
822
+
823
+ info_msg = ""
824
+ if filter_msg:
825
+ info_msg += filter_msg
826
+ info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
827
+ info_msg += f"Confluency: {confluency:.1f}%\n"
828
+ info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
829
+ if use_stereology:
830
+ info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
831
+ info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
832
+ info_msg += "Now run the viability classification model for viability assessment."
833
+
834
+ return (
835
+ cell_count,
836
+ overlay_pil,
837
+ info_msg,
838
+ gr.update(visible=True),
839
+ pack_array(masks),
840
+ pack_array(processed_image_np),
841
+ confluency,
842
+ gr.update(value=rec_msg),
843
+ pack_array(raw_image_np),
844
+ pack_array(base_masks),
845
+ )
846
+
847
+ except Exception as e:
848
+ import traceback
849
+ traceback.print_exc()
850
+ return (
851
+ 0,
852
+ None,
853
+ f"Error during segmentation: {str(e)}",
854
+ gr.update(visible=False),
855
+ None,
856
+ None,
857
+ 0.0,
858
+ gr.update(),
859
+ None,
860
+ None,
861
+ )
862
+
863
+
864
+ def run_viability(stored_masks, stored_image_np):
865
+ """Run model-based viability classification. Returns overlay + counts + label_map."""
866
+ if stored_masks is None or stored_image_np is None:
867
+ return None, 0, 0, 0.0, "Please run segmentation first.", {}
868
+ if VIABILITY_CLF is None:
869
+ return None, 0, 0, 0.0, "No viability model found. Add viability_clf.pkl and viability_scaler.pkl to the app directory.", {}
870
+
871
+ masks = unpack_array(stored_masks)
872
+ image_np = unpack_array(stored_image_np)
873
+
874
+ try:
875
+ dead, alive, overlay_np, label_map = classify_cells_by_model(image_np, masks)
876
+ total = alive + dead
877
+ viab_pct = (alive / total * 100) if total > 0 else 0.0
878
+ confluency = measure_confluency(masks, image_np)
879
+ info_msg = f"Total cells: {total}\nLive (green): {alive}\nDead (red): {dead}\n"
880
+ info_msg += f"Viability: {viab_pct:.1f}%\nConfluency: {confluency:.1f}%"
881
+ return Image.fromarray(overlay_np), alive, dead, viab_pct, info_msg, label_map
882
+ except Exception as e:
883
+ import traceback; traceback.print_exc()
884
+ return None, 0, 0, 0.0, f"Error: {str(e)}", {}
885
+
886
+
887
+ def pack_array(arr):
888
+ """
889
+ Serialise an array for gr.State.
890
+
891
+ Uses np.save rather than a PNG: label masks are int32 and routinely carry
892
+ more than 255 cell ids, which a uint8 PNG silently wraps (id 256 becomes
893
+ background). Dtype and values are preserved exactly.
894
+ """
895
+ buf = io.BytesIO()
896
+ np.save(buf, arr, allow_pickle=False)
897
+ return buf.getvalue()
898
+
899
+
900
+ def unpack_array(data):
901
+ buf = io.BytesIO(data)
902
+ try:
903
+ return np.load(buf, allow_pickle=False)
904
+ except ValueError:
905
+ # Legacy PNG-encoded state from an older session
906
+ buf.seek(0)
907
+ return np.array(Image.open(buf))
908
+
909
+
910
+ def save_tab_result(cell_count, confluency, viab_percent):
911
+ """Package per-tab results into a dict for Tab 5 summary."""
912
+ return {
913
+ "cell_count": float(cell_count) if cell_count is not None else None,
914
+ "confluency": float(confluency) if confluency is not None else None,
915
+ "viab_percent": float(viab_percent) if viab_percent is not None else None,
916
+ }
917
+
918
+
919
+ def compute_summary(r1, r2, r3, r4):
920
+ """Average cell count, confluency, and viability across tabs that have data."""
921
+ all_results = [r1, r2, r3, r4]
922
+ valid = [(i + 1, r) for i, r in enumerate(all_results)
923
+ if r is not None and r.get("cell_count") is not None]
924
+
925
+ if not valid:
926
+ return (
927
+ 0.0, 0.0, 0.0,
928
+ "No data yet β€” run segmentation in at least one tab, then click Refresh Summary."
929
+ )
930
+
931
+ avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
932
+ avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
933
+ avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
934
+
935
+ lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
936
+ f"{r['confluency']:.1f}% confluency | "
937
+ f"{r['viab_percent']:.1f}% viability"
938
+ for tab_num, r in valid]
939
+ lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
940
+ lines.append(f" Cell count: {avg_count:.1f}")
941
+ lines.append(f" Confluency: {avg_conf:.1f}%")
942
+ lines.append(f" Viability: {avg_viab:.1f}%")
943
+
944
+ return avg_count, avg_conf, avg_viab, "\n".join(lines)
945
+
946
+
947
+ # ---------------------------------------------------------------------------
948
+ # Training data export β€” feature extraction per cell
949
+ # ---------------------------------------------------------------------------
950
+
951
+ def extract_cell_features(image_np, masks):
952
+ """
953
+ For every segmented cell, extract a fixed feature vector from the pixels
954
+ inside its mask. Returns a list of dicts, one per cell.
955
+
956
+ Features:
957
+ RGB channels β€” mean_r, mean_g, mean_b, std_r, std_g, std_b
958
+ HSV channels β€” mean_h, mean_s, mean_v, std_s, std_v
959
+ Ratios β€” blue_red_ratio, blue_green_ratio, rg_ratio
960
+ Morphology β€” area_px, circularity
961
+ Centre/edge profile β€” inner_brightness, peak_brightness,
962
+ bright_spot_fraction, ring_darkness,
963
+ centre_periphery_ratio, brightness_std_normalised
964
+
965
+ Profile zones are tuned to hemocytometer live-cell morphology:
966
+ a small intense specular highlight at the centre surrounded by a dark
967
+ navy membrane ring. Dead cells are pale blue-grey blobs with no ring
968
+ and no bright spot.
969
+ """
970
+ if len(image_np.shape) == 2:
971
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
972
+ elif image_np.shape[2] == 4:
973
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
974
+
975
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV).astype(np.float32)
976
+
977
+ h_img, w_img = image_np.shape[:2]
978
+ grid_y, grid_x = np.mgrid[:h_img, :w_img]
979
+
980
+ cell_ids = np.unique(masks)
981
+ cell_ids = cell_ids[cell_ids > 0]
982
+ rows = []
983
+
984
+ for cid in cell_ids:
985
+ cell_mask = (masks == cid)
986
+ pixels_rgb = image_np[cell_mask].astype(np.float32)
987
+ pixels_hsv = hsv[cell_mask]
988
+
989
+ r, g, b = pixels_rgb[:, 0], pixels_rgb[:, 1], pixels_rgb[:, 2]
990
+ h, s, v = pixels_hsv[:, 0], pixels_hsv[:, 1], pixels_hsv[:, 2]
991
+
992
+ eps = 1e-6
993
+ blue_red_ratio = b.mean() / (r.mean() + eps)
994
+ blue_green_ratio = b.mean() / (g.mean() + eps)
995
+ rg_ratio = r.mean() / (g.mean() + eps)
996
+
997
+ area_px = int(cell_mask.sum())
998
+ contours, _ = cv2.findContours(
999
+ cell_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
1000
+ )
1001
+ perimeter = cv2.arcLength(contours[0], True) if contours else 1.0
1002
+ circularity = (4 * np.pi * area_px / (perimeter ** 2 + eps)) if perimeter > 0 else 0.0
1003
+
1004
+ ys_cell = grid_y[cell_mask].astype(np.float32)
1005
+ xs_cell = grid_x[cell_mask].astype(np.float32)
1006
+ centroid_y = ys_cell.mean()
1007
+ centroid_x = xs_cell.mean()
1008
+
1009
+ cell_radius = np.sqrt(area_px / np.pi) + eps
1010
+ dist_norm = np.sqrt((xs_cell - centroid_x)**2 + (ys_cell - centroid_y)**2) / cell_radius
1011
+
1012
+ v_all = hsv[:, :, 2][cell_mask]
1013
+
1014
+ # Tight inner core (15% radius) β€” captures specular highlight spot only
1015
+ inner_mask = dist_norm < 0.15
1016
+ # Membrane ring zone (20-60%) β€” dark navy ring on live cells
1017
+ ring_mask = (dist_norm >= 0.20) & (dist_norm <= 0.60)
1018
+ # Outer zone (>60%) β€” denominator for centre ratio
1019
+ outer_mask = dist_norm > 0.60
1020
+
1021
+ inner_brightness = float(v_all[inner_mask].mean()) if inner_mask.any() else float(v.mean())
1022
+ ring_brightness = float(v_all[ring_mask].mean()) if ring_mask.any() else float(v.mean())
1023
+ outer_brightness = float(v_all[outer_mask].mean()) if outer_mask.any() else float(v.mean())
1024
+
1025
+ # Peak V β€” specular spot is just a few pixels so mean dilutes it
1026
+ peak_brightness = float(v_all.max())
1027
+
1028
+ # Fraction of cell pixels with V > 200 (specular highlight region)
1029
+ bright_spot_fraction = float((v_all > 200).sum()) / (len(v_all) + eps)
1030
+
1031
+ # Ring darkness: ratio of ring zone to outer zone brightness
1032
+ # Live: ring << outer (dark membrane ring) -> ratio < 1
1033
+ # Dead: uniform blob -> ratio ~ 1
1034
+ ring_darkness = ring_brightness / (outer_brightness + eps)
1035
+
1036
+ centre_periphery_ratio = inner_brightness / (outer_brightness + eps)
1037
+
1038
+ brightness_std_normalised = float(v.std()) / (float(v.mean()) + eps)
1039
+
1040
+ rows.append({
1041
+ "cell_id": int(cid),
1042
+ "mean_r": float(r.mean()),
1043
+ "mean_g": float(g.mean()),
1044
+ "mean_b": float(b.mean()),
1045
+ "std_r": float(r.std()),
1046
+ "std_g": float(g.std()),
1047
+ "std_b": float(b.std()),
1048
+ "mean_h": float(h.mean()),
1049
+ "mean_s": float(s.mean()),
1050
+ "mean_v": float(v.mean()),
1051
+ "std_s": float(s.std()),
1052
+ "std_v": float(v.std()),
1053
+ "blue_red_ratio": round(blue_red_ratio, 5),
1054
+ "blue_green_ratio": round(blue_green_ratio, 5),
1055
+ "rg_ratio": round(rg_ratio, 5),
1056
+ "area_px": area_px,
1057
+ "circularity": round(float(circularity), 5),
1058
+ "inner_brightness": round(inner_brightness, 3),
1059
+ "peak_brightness": round(peak_brightness, 3),
1060
+ "bright_spot_fraction": round(bright_spot_fraction, 6),
1061
+ "ring_darkness": round(ring_darkness, 5),
1062
+ "centre_periphery_ratio": round(centre_periphery_ratio, 5),
1063
+ "brightness_std_normalised": round(brightness_std_normalised, 5),
1064
+ })
1065
+
1066
+ return rows
1067
+
1068
+ def attach_viability_labels(cell_features, masks, image_np, label_map=None):
1069
+ """
1070
+ Attach model predictions (from label_map) to each feature dict.
1071
+ label_map: {cell_id: 0=live, 1=dead} from classify_cells_by_model.
1072
+ If label_map is None, defaults all labels to 0 (live).
1073
+ """
1074
+ if not cell_features:
1075
+ return []
1076
+ labelled = []
1077
+ for feat in cell_features:
1078
+ row = dict(feat)
1079
+ cid = int(feat["cell_id"])
1080
+ row["label"] = int(label_map.get(cid, 0)) if label_map else 0
1081
+ row["corrected"] = False
1082
+ labelled.append(row)
1083
+ return labelled
1084
+
1085
+
1086
+ def export_cell_data_csv(cell_data):
1087
+ """Write cell_data list-of-dicts to a temp CSV and return the file path."""
1088
+ if not cell_data:
1089
+ return None
1090
+ tmp = tempfile.NamedTemporaryFile(
1091
+ mode="w", suffix=".csv", delete=False, newline=""
1092
+ )
1093
+ # Union of all keys across rows so any late-added keys (e.g. "corrected") are included
1094
+ fieldnames = list(dict.fromkeys(k for row in cell_data for k in row.keys()))
1095
+ writer = csv.DictWriter(tmp, fieldnames=fieldnames, extrasaction="ignore")
1096
+ writer.writeheader()
1097
+ writer.writerows(cell_data)
1098
+ tmp.close()
1099
+ return tmp.name
1100
+
1101
+
1102
+ def prepare_export(stored_masks, stored_image, threshold_bias):
1103
+ """
1104
+ Called by the Export button. Unpacks state, extracts features,
1105
+ attaches labels, writes CSV, returns (path, status_message).
1106
+ """
1107
+ if stored_masks is None or stored_image is None:
1108
+ return None, "Run segmentation first before exporting."
1109
+
1110
+ masks = unpack_array(stored_masks)
1111
+ image_np = unpack_array(stored_image)
1112
+
1113
+ features = extract_cell_features(image_np, masks)
1114
+ if not features:
1115
+ return None, "No cells found to export."
1116
+
1117
+ labelled = attach_viability_labels(features, masks, image_np, threshold_bias)
1118
+ path = export_cell_data_csv(labelled)
1119
+
1120
+ n = len(labelled)
1121
+ dead = sum(1 for r in labelled if r["label"] == 1)
1122
+ alive = n - dead
1123
+ msg = (f"Exported {n} cells ({alive} live, {dead} dead) β€” "
1124
+ f"threshold bias={threshold_bias:+d}.\n"
1125
+ f"Columns: {', '.join(list(labelled[0].keys())[:6])}… "
1126
+ f"({len(labelled[0])} total).")
1127
+ return path, msg
1128
+
1129
+
1130
+ # ---------------------------------------------------------------------------
1131
+ # Tab builder
1132
+ # ---------------------------------------------------------------------------
1133
+
1134
+ def draw_polygon_overlay(image_pil, points):
1135
+ """
1136
+ Draw numbered vertex dots and polygon edges onto a copy of image_pil.
1137
+ points: list of (x, y) tuples in original image pixel space.
1138
+ Returns a new PIL image.
1139
+ """
1140
+ img = image_pil.copy().convert("RGBA")
1141
+ overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
1142
+ draw = ImageDraw.Draw(overlay)
1143
+
1144
+ if len(points) >= 2:
1145
+ # Draw edges
1146
+ for i in range(len(points) - 1):
1147
+ draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
1148
+ if len(points) == 4:
1149
+ draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
1150
+ # Semi-transparent fill
1151
+ draw.polygon(points, fill=(74, 170, 255, 50))
1152
+
1153
+ # Draw vertex dots + numbers
1154
+ r = max(8, min(img.width, img.height) // 60)
1155
+ for i, (x, y) in enumerate(points):
1156
+ draw.ellipse([x - r, y - r, x + r, y + r],
1157
+ fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
1158
+ draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
1159
+
1160
+ combined = Image.alpha_composite(img, overlay)
1161
+ return combined.convert("RGB")
1162
+
1163
+
1164
+ def add_crop_point(image_pil, points, evt: gr.SelectData):
1165
+ """
1166
+ Called by gr.Image .select(). Appends the clicked coordinate,
1167
+ redraws the overlay, returns (updated_image, updated_points).
1168
+ Ignores clicks once 4 points are set.
1169
+ """
1170
+ if image_pil is None:
1171
+ return image_pil, points
1172
+ if points is None:
1173
+ points = []
1174
+ if len(points) >= 4:
1175
+ return draw_polygon_overlay(image_pil, points), points
1176
+
1177
+ x, y = int(evt.index[0]), int(evt.index[1])
1178
+ new_points = points + [(x, y)]
1179
+ return draw_polygon_overlay(image_pil, new_points), new_points
1180
+
1181
+
1182
+ def clear_crop_points(image_pil):
1183
+ """Reset polygon β€” return original image with no overlay and empty points."""
1184
+ return image_pil, []
1185
+
1186
+
1187
+
1188
+
1189
+
1190
+ # ---------------------------------------------------------------------------
1191
+ # Label correction grid
1192
+ # ---------------------------------------------------------------------------
1193
+
1194
+ THUMB_SIZE = 80 # each cell thumbnail is THUMB_SIZE Γ— THUMB_SIZE px
1195
+ GRID_COLS = 8 # thumbnails per row
1196
+ BORDER = 4 # coloured border thickness in px
1197
+ LABEL_H = 16 # height of the text label strip at the bottom of each thumb
1198
+
1199
+ def _crop_cell_thumb(image_np, masks, cid):
1200
+ """
1201
+ Return a tight square crop of the cell, padded to THUMB_SIZE Γ— THUMB_SIZE.
1202
+ """
1203
+ ys, xs = np.where(masks == cid)
1204
+ if len(ys) == 0:
1205
+ return Image.fromarray(np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8))
1206
+
1207
+ y0, y1 = ys.min(), ys.max() + 1
1208
+ x0, x1 = xs.min(), xs.max() + 1
1209
+
1210
+ # add a small context border around the tight bounding box
1211
+ pad = max(4, int(max(y1 - y0, x1 - x0) * 0.15))
1212
+ h, w = image_np.shape[:2]
1213
+ y0c = max(0, y0 - pad)
1214
+ y1c = min(h, y1 + pad)
1215
+ x0c = max(0, x0 - pad)
1216
+ x1c = min(w, x1 + pad)
1217
+
1218
+ crop = image_np[y0c:y1c, x0c:x1c].copy()
1219
+
1220
+ # dim pixels that don't belong to this cell
1221
+ dim_mask = (masks[y0c:y1c, x0c:x1c] != cid)
1222
+ crop[dim_mask] = (crop[dim_mask] * 0.3).astype(np.uint8)
1223
+
1224
+ pil = Image.fromarray(crop).resize((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
1225
+ return pil
1226
+
1227
+
1228
+ def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
1229
+ """
1230
+ Render all cell thumbnails into a single PIL image grid.
1231
+ Each thumbnail has a coloured border: green=live(0), red=dead(1).
1232
+ A small number in the corner identifies the cell_id.
1233
+
1234
+ Returns the PIL grid image.
1235
+ Cell order in the grid matches the order of labelled_features.
1236
+ """
1237
+ if not labelled_features:
1238
+ placeholder = Image.fromarray(
1239
+ np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
1240
+ )
1241
+ return placeholder
1242
+
1243
+ thumb_src = raw_image_np if raw_image_np is not None else image_np
1244
+
1245
+ n = len(labelled_features)
1246
+ n_cols = GRID_COLS
1247
+ n_rows = (n + n_cols - 1) // n_cols
1248
+
1249
+ cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1250
+ cell_w = THUMB_SIZE + 2 * BORDER
1251
+
1252
+ grid_w = n_cols * cell_w
1253
+ grid_h = n_rows * cell_h
1254
+
1255
+ grid = Image.new("RGB", (grid_w, grid_h), (30, 30, 30))
1256
+ draw = ImageDraw.Draw(grid)
1257
+
1258
+ for idx, feat in enumerate(labelled_features):
1259
+ cid = feat["cell_id"]
1260
+ label = feat["label"] # 0=live, 1=dead (may have been corrected)
1261
+ color = (220, 50, 50) if label == 1 else (50, 200, 80)
1262
+
1263
+ thumb = _crop_cell_thumb(thumb_src, masks, cid)
1264
+
1265
+ col = idx % n_cols
1266
+ row = idx // n_cols
1267
+ x0 = col * cell_w
1268
+ y0 = row * cell_h
1269
+
1270
+ # coloured border rectangle
1271
+ draw.rectangle([x0, y0, x0 + cell_w - 1, y0 + cell_h - 1], outline=color, width=BORDER)
1272
+
1273
+ # paste thumbnail inside border
1274
+ grid.paste(thumb, (x0 + BORDER, y0 + BORDER))
1275
+
1276
+ # small cell-id label strip
1277
+ strip_y = y0 + BORDER + THUMB_SIZE
1278
+ draw.rectangle([x0, strip_y, x0 + cell_w - 1, y0 + cell_h - 1],
1279
+ fill=(20, 20, 20))
1280
+ draw.text((x0 + BORDER + 2, strip_y + 1),
1281
+ f"#{cid} {'D' if label == 1 else 'L'}",
1282
+ fill=color)
1283
+
1284
+ return grid
1285
+
1286
+
1287
+ def toggle_cell_label(labelled_features, image_np, masks, raw_image_np, evt: gr.SelectData):
1288
+ """
1289
+ Called when user taps the correction grid image.
1290
+ Maps the tap pixel coordinate back to which thumbnail was tapped,
1291
+ flips that cell's label, rebuilds and returns the updated grid.
1292
+ """
1293
+ if not labelled_features or image_np is None:
1294
+ return build_correction_grid(image_np, masks, labelled_features), labelled_features
1295
+
1296
+ cell_w = THUMB_SIZE + 2 * BORDER
1297
+ cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1298
+
1299
+ px, py = int(evt.index[0]), int(evt.index[1])
1300
+ col = px // cell_w
1301
+ row = py // cell_h
1302
+ idx = row * GRID_COLS + col
1303
+
1304
+ if idx < 0 or idx >= len(labelled_features):
1305
+ return build_correction_grid(image_np, masks, labelled_features, raw_image_np), labelled_features
1306
+
1307
+ # Flip the label
1308
+ updated = list(labelled_features) # shallow copy of list
1309
+ cell = dict(updated[idx]) # copy the dict so we don't mutate in place
1310
+ cell["label"] = 1 - cell["label"] # 0β†’1 or 1β†’0
1311
+ cell["corrected"] = True
1312
+ updated[idx] = cell
1313
+
1314
+ grid = build_correction_grid(image_np, masks, updated, raw_image_np)
1315
+ n_corrected = sum(1 for f in updated if f.get("corrected"))
1316
+ return grid, updated, f"Tapped cell #{cell['cell_id']} β†’ {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1317
+
1318
+
1319
+ def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1320
+ """Export CSV using labelled_features with any manual corrections applied."""
1321
+ if stored_masks is None or stored_image is None:
1322
+ return None, "Run segmentation first before exporting."
1323
+ masks = unpack_array(stored_masks)
1324
+ image_np = unpack_array(stored_image)
1325
+ if not labelled_features:
1326
+ features = extract_cell_features(image_np, masks)
1327
+ labelled_features = attach_viability_labels(features, masks, image_np, label_map)
1328
+ if not labelled_features:
1329
+ return None, "No cells found to export."
1330
+ path = export_cell_data_csv(labelled_features)
1331
+ n = len(labelled_features)
1332
+ dead = sum(1 for r in labelled_features if r["label"] == 1)
1333
+ alive = n - dead
1334
+ corrected = sum(1 for r in labelled_features if r.get("corrected"))
1335
+ msg = (f"Exported {n} cells ({alive} live, {dead} dead). "
1336
+ f"{corrected} label(s) manually corrected.")
1337
+ return path, msg
1338
+
1339
+ def build_tab(tab_index, masks_state, image_state, result_state):
1340
+ with gr.Tab(f"Tab {tab_index}"):
1341
+ gr.Markdown("Run segmentation")
1342
+
1343
+ # Per-tab state: list of (x,y) crop polygon points
1344
+ crop_points_state = gr.State(value=[])
1345
+ # Clean copy of the uploaded image (no polygon drawn on it)
1346
+ base_image_state = gr.State(value=None)
1347
+ #raw image state
1348
+ raw_image_state = gr.State(value=None)
1349
+ # Masks as segmented, before stereological exclusion β€” lets the zones
1350
+ # be re-chosen after segmentation without re-running the model.
1351
+ base_masks_state = gr.State(value=None)
1352
+
1353
+ with gr.Row():
1354
+ with gr.Column():
1355
+ img_input = gr.Image(
1356
+ type="pil",
1357
+ label="Upload image",
1358
+ image_mode="RGB",
1359
+ height=512
1360
+ )
1361
+
1362
+ gr.Markdown(
1363
+ "### Crop region (optional)\n"
1364
+ "Click/tap up to **4 points** on the image below to define the region "
1365
+ "to segment. The polygon will be drawn as you click. "
1366
+ "Leave empty to segment the full image."
1367
+ )
1368
+
1369
+ crop_display = gr.Image(
1370
+ type="pil",
1371
+ label="Click to set crop vertices (up to 4)",
1372
+ interactive=True,
1373
+ height=400,
1374
+ )
1375
+
1376
+ crop_status = gr.Markdown("*Upload an image to enable cropping*")
1377
+
1378
+ clear_crop_btn = gr.Button("βœ• Clear crop points", size="sm")
1379
+
1380
+ model_dropdown = gr.Dropdown(
1381
+ choices=list(MODEL_OPTIONS.keys()),
1382
+ label="Select Model",
1383
+ value="Hemocytometer Model"
1384
+ )
1385
+
1386
+ gr.Markdown("### Size Filters")
1387
+
1388
+ use_min_filter = gr.Checkbox(
1389
+ label="Enable minimum size filter",
1390
+ value=False,
1391
+ info="Remove objects smaller than the threshold below"
1392
+ )
1393
+ min_size_slider = gr.Slider(
1394
+ minimum=0,
1395
+ maximum=500,
1396
+ value=0,
1397
+ step=10,
1398
+ label="Minimum Cell Size (pixels)",
1399
+ interactive=False,
1400
+ )
1401
+ min_size_recommendation = gr.Markdown(
1402
+ value="*Run segmentation to see recommended minimum*",
1403
+ )
1404
+
1405
+ use_max_filter = gr.Checkbox(
1406
+ label="Enable maximum size filter",
1407
+ value=False,
1408
+ info="Remove objects larger than the threshold below"
1409
+ )
1410
+ max_size_slider = gr.Slider(
1411
+ minimum=0,
1412
+ maximum=10000,
1413
+ value=10000,
1414
+ step=10,
1415
+ label="Maximum Cell Size (pixels)",
1416
+ interactive=False,
1417
+ )
1418
+
1419
+ segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
1420
+
1421
+ with gr.Column():
1422
+ cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
1423
+ confluency_out = gr.Number(label="Confluency (%)", precision=1)
1424
+ overlay_out = gr.Image(type="pil", label="Segmentation Result")
1425
+ info_out = gr.Textbox(label="Processing Info", lines=4)
1426
+
1427
+ # Applied after segmentation, so it lives beside the result it
1428
+ # modifies rather than with the pre-segmentation settings.
1429
+ gr.Markdown("### Stereological Counting")
1430
+ use_stereo = gr.Checkbox(
1431
+ label="Enable Stereological Counting",
1432
+ value=False,
1433
+ info="Applied to the segmentation above β€” no re-segmenting needed"
1434
+ )
1435
+
1436
+ with gr.Group(visible=False) as stereo_controls:
1437
+ gr.Markdown("""
1438
+ **Stereological Counting Rules:**
1439
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1440
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
1441
+ - This provides unbiased counting for quantification
1442
+
1443
+ Zone widths are percentages of the **segmented image** β€”
1444
+ the cropped and perspective-corrected region, not the
1445
+ original upload. The red zones are drawn on the
1446
+ segmentation result above and update as you drag.
1447
+ """)
1448
+
1449
+ left_excl = gr.Slider(
1450
+ minimum=0,
1451
+ maximum=50,
1452
+ value=10,
1453
+ step=1,
1454
+ label="Left Exclusion Width (%)",
1455
+ info="Width of left exclusion zone"
1456
+ )
1457
+
1458
+ top_excl = gr.Slider(
1459
+ minimum=0,
1460
+ maximum=50,
1461
+ value=10,
1462
+ step=1,
1463
+ label="Top Exclusion Width (%)",
1464
+ info="Width of top exclusion zone"
1465
+ )
1466
+
1467
+ with gr.Group(visible=False) as viability_section:
1468
+ gr.Markdown("### Viability Assessment (Trypan Blue)")
1469
+
1470
+ viab_run_btn = gr.Button("Run Viability Analysis", variant="primary")
1471
+
1472
+ with gr.Row():
1473
+ live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
1474
+ dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
1475
+
1476
+ viab_overlay = gr.Image(type="pil", label="Viability (Green=Live Β· Red=Dead)")
1477
+ viab_percent_out = gr.Number(label="Viability (%)", precision=1)
1478
+ viab_info = gr.Textbox(label="Analysis Results", lines=4)
1479
+
1480
+ gr.Markdown("### Label Correction & Export")
1481
+ gr.Markdown(
1482
+ "After running viability, click **Build correction grid** to review every cell. "
1483
+ "**Green border = Live, Red border = Dead** (model predictions). "
1484
+ "Tap any thumbnail to flip its label β€” the counts and overlay update instantly. "
1485
+ "Export the corrected CSV for retraining."
1486
+ )
1487
+
1488
+ build_grid_btn = gr.Button("πŸ”² Build correction grid", variant="secondary")
1489
+ labelled_state = gr.State(value=[])
1490
+ label_map_state = gr.State(value={})
1491
+
1492
+ correction_grid = gr.Image(
1493
+ type="pil",
1494
+ label="Tap a cell to flip its label (green=live Β· red=dead)",
1495
+ interactive=True,
1496
+ visible=False,
1497
+ )
1498
+ correction_status = gr.Markdown(visible=False)
1499
+
1500
+ with gr.Row():
1501
+ export_btn = gr.Button("⬇️ Export corrected CSV", variant="secondary")
1502
+ export_info = gr.Textbox(label="Export status", lines=2, interactive=False)
1503
+ export_file = gr.File(label="Download CSV", visible=False)
1504
+
1505
+ # ---- Event handlers ------------------------------------------------
1506
+
1507
+ use_stereo.change(
1508
+ fn=toggle_stereological_mode,
1509
+ inputs=[use_stereo],
1510
+ outputs=[stereo_controls]
1511
+ )
1512
+
1513
+ def on_image_upload(img):
1514
+ if img is None:
1515
+ return None, None, "*Upload an image to enable cropping*"
1516
+ return img, img, "*Image loaded β€” click up to 4 points to define crop region*"
1517
+
1518
+ img_input.change(
1519
+ fn=on_image_upload,
1520
+ inputs=[img_input],
1521
+ outputs=[crop_display, base_image_state, crop_status]
1522
+ ).then(fn=lambda: [], outputs=[crop_points_state])
1523
+
1524
+ # Grey the size sliders out while their filter is disabled, so the
1525
+ # checkbox state is visible rather than silently inferred.
1526
+ use_min_filter.change(
1527
+ fn=lambda on: gr.update(interactive=bool(on)),
1528
+ inputs=[use_min_filter], outputs=[min_size_slider]
1529
+ )
1530
+ use_max_filter.change(
1531
+ fn=lambda on: gr.update(interactive=bool(on)),
1532
+ inputs=[use_max_filter], outputs=[max_size_slider]
1533
+ )
1534
+
1535
+ def on_exclusion_change(stored_base, stored_image, use_stereo_on,
1536
+ left_pct, top_pct):
1537
+ """Re-apply the exclusion zones to existing masks β€” no re-segmentation."""
1538
+ if stored_base is None or stored_image is None:
1539
+ return (gr.update(), gr.update(), gr.update(),
1540
+ gr.update(), gr.update())
1541
+ base_masks = unpack_array(stored_base)
1542
+ image_np = unpack_array(stored_image)
1543
+ masks, cell_count, confluency, overlay_pil, excluded = render_segmentation(
1544
+ base_masks, image_np, use_stereo_on, left_pct, top_pct
1545
+ )
1546
+ if use_stereo_on:
1547
+ msg = (f"Stereological exclusion (Left: {left_pct}%, Top: {top_pct}%): "
1548
+ f"{excluded} cells excluded, {cell_count} counted.\n"
1549
+ f"Confluency: {confluency:.1f}%\n"
1550
+ f"Re-run viability classification to update live/dead counts.")
1551
+ else:
1552
+ msg = (f"Stereological exclusion off β€” {cell_count} cells counted.\n"
1553
+ f"Confluency: {confluency:.1f}%")
1554
+ return cell_count, overlay_pil, confluency, pack_array(masks), msg
1555
+
1556
+ exclusion_inputs = [base_masks_state, image_state, use_stereo,
1557
+ left_excl, top_excl]
1558
+ exclusion_outputs = [cell_count_out, overlay_out, confluency_out,
1559
+ masks_state, info_out]
1560
+ for _component in (use_stereo, left_excl, top_excl):
1561
+ _component.change(fn=on_exclusion_change,
1562
+ inputs=exclusion_inputs, outputs=exclusion_outputs)
1563
+
1564
+ def on_crop_click(base_img, points, evt: gr.SelectData):
1565
+ updated_img, updated_pts = add_crop_point(base_img, points, evt)
1566
+ n = len(updated_pts)
1567
+ status = (f"*{n} / 4 points set β€” keep clicking*" if n < 4
1568
+ else "*4 points set βœ“ β€” click **βœ• Clear** to redo, or run segmentation*")
1569
+ return updated_img, updated_pts, status
1570
+
1571
+ crop_display.select(fn=on_crop_click,
1572
+ inputs=[base_image_state, crop_points_state],
1573
+ outputs=[crop_display, crop_points_state, crop_status])
1574
+
1575
+ def on_clear_crop(base_img):
1576
+ img, pts = clear_crop_points(base_img)
1577
+ return img, pts, "*Points cleared β€” click to set new vertices*"
1578
+
1579
+ clear_crop_btn.click(fn=on_clear_crop,
1580
+ inputs=[base_image_state],
1581
+ outputs=[crop_display, crop_points_state, crop_status])
1582
+
1583
+ segment_btn.click(
1584
+ fn=run_segmentation,
1585
+ inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1586
+ use_stereo, left_excl, top_excl, crop_points_state,
1587
+ use_min_filter, use_max_filter],
1588
+ outputs=[cell_count_out, overlay_out, info_out, viability_section,
1589
+ masks_state, image_state, confluency_out,
1590
+ min_size_recommendation, raw_image_state, base_masks_state]
1591
+ )
1592
+
1593
+ # ---- Run Viability button -------------------------------------------
1594
+ def on_run_viability(stored_masks, stored_image):
1595
+ overlay, alive, dead, viab_pct, info, label_map = run_viability(stored_masks, stored_image)
1596
+ return overlay, alive, dead, viab_pct, info, label_map
1597
+
1598
+ viab_run_btn.click(
1599
+ fn=on_run_viability,
1600
+ inputs=[masks_state, image_state],
1601
+ outputs=[viab_overlay, live_count_out, dead_count_out,
1602
+ viab_percent_out, viab_info, label_map_state]
1603
+ ).then(
1604
+ fn=save_tab_result,
1605
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
1606
+ outputs=[result_state]
1607
+ )
1608
+
1609
+ # ---- Build correction grid -----------------------------------------
1610
+ def on_build_grid(stored_masks, stored_image, label_map, stored_raw_image):
1611
+ if stored_masks is None or stored_image is None or not label_map:
1612
+ return (gr.update(visible=False), [],
1613
+ gr.update(value="*Run viability analysis first.*", visible=True))
1614
+ masks = unpack_array(stored_masks)
1615
+ image_np = unpack_array(stored_image)
1616
+ raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1617
+ features = extract_cell_features(image_np, masks)
1618
+ labelled = attach_viability_labels(features, masks, image_np, label_map)
1619
+ if not labelled:
1620
+ return (gr.update(visible=False), [],
1621
+ gr.update(value="*No cells found.*", visible=True))
1622
+ grid = build_correction_grid(image_np, masks, labelled, raw_image_np)
1623
+ n = len(labelled)
1624
+ dead = sum(1 for r in labelled if r["label"] == 1)
1625
+ msg = (f"*{n} cells β€” {n-dead} live (green), {dead} dead (red). "
1626
+ f"Tap any thumbnail to flip its label.*")
1627
+ return gr.update(value=grid, visible=True), labelled, gr.update(value=msg, visible=True)
1628
+
1629
+ build_grid_btn.click(
1630
+ fn=on_build_grid,
1631
+ inputs=[masks_state, image_state, label_map_state, raw_image_state],
1632
+ outputs=[correction_grid, labelled_state, correction_status]
1633
+ )
1634
+
1635
+ # ---- Grid tap β€” flip label, update overlay + counts ----------------
1636
+ def on_grid_tap(labelled, stored_masks, stored_image, stored_raw_image, evt: gr.SelectData):
1637
+ if not labelled or stored_masks is None:
1638
+ return None, labelled, "", 0, 0, 0.0, None, {}
1639
+ masks = unpack_array(stored_masks)
1640
+ image_np = unpack_array(stored_image)
1641
+ raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1642
+ grid, updated, msg = toggle_cell_label(labelled, image_np, masks, raw_image_np, evt)
1643
+
1644
+ # Rebuild label_map from corrected labelled list
1645
+ new_label_map = {int(f["cell_id"]): int(f["label"]) for f in updated}
1646
+ overlay_np = draw_viability_overlay(image_np, masks, new_label_map)
1647
+ dead = sum(1 for f in updated if f["label"] == 1)
1648
+ alive = len(updated) - dead
1649
+ total = alive + dead
1650
+ viab_pct = (alive / total * 100) if total > 0 else 0.0
1651
+
1652
+ return (grid, updated, f"*{msg}*",
1653
+ alive, dead, viab_pct,
1654
+ Image.fromarray(overlay_np), new_label_map)
1655
+
1656
+ correction_grid.select(
1657
+ fn=on_grid_tap,
1658
+ inputs=[labelled_state, masks_state, image_state, raw_image_state],
1659
+ outputs=[correction_grid, labelled_state, correction_status,
1660
+ live_count_out, dead_count_out, viab_percent_out,
1661
+ viab_overlay, label_map_state]
1662
+ )
1663
+
1664
+ # ---- Export --------------------------------------------------------
1665
+ def on_export(stored_masks, stored_image, labelled, label_map):
1666
+ path, msg = prepare_export_corrected(stored_masks, stored_image, labelled, label_map)
1667
+ if path is None:
1668
+ return gr.update(visible=False), msg
1669
+ return gr.update(value=path, visible=True), msg
1670
+
1671
+ export_btn.click(
1672
+ fn=on_export,
1673
+ inputs=[masks_state, image_state, labelled_state, label_map_state],
1674
+ outputs=[export_file, export_info]
1675
+ )
1676
+
1677
+
1678
+
1679
+ # ---------------------------------------------------------------------------
1680
+ # Gradio interface
1681
+ # ---------------------------------------------------------------------------
1682
+ with gr.Blocks(
1683
+ title="CellposeCellCounter",
1684
+ theme=gr.themes.Soft(),
1685
+ ) as demo:
1686
+ gr.Markdown("# CellposeCellCounter")
1687
+ gr.Markdown("For accurate cell confluency, crop the image to display only desired area. Note that some image file types are not yet supported. PNG and JPEG are preferred.")
1688
+
1689
+ # Shared mask/image state (one pair per tab so tabs don't clobber each other)
1690
+ masks_states = [gr.State(value=None) for _ in range(4)]
1691
+ image_states = [gr.State(value=None) for _ in range(4)]
1692
+ result_states = [gr.State(value=None) for _ in range(4)]
1693
+
1694
+ # Build Tabs 1–4 with a loop
1695
+ for i in range(4):
1696
+ build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1697
+
1698
+ # -------------------------------------------------------------------------
1699
+ # Tab 5 β€” Summary
1700
+ # -------------------------------------------------------------------------
1701
+ with gr.Tab("Tab 5 β€” Summary"):
1702
+ gr.Markdown("## Average Results Across All Tabs")
1703
+ gr.Markdown(
1704
+ "Run segmentation in one or more tabs, "
1705
+ "then click **Refresh Summary** to see the averages."
1706
+ )
1707
+
1708
+ refresh_btn = gr.Button("πŸ”„ Refresh Summary", variant="primary", size="lg")
1709
+
1710
+ with gr.Row():
1711
+ avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
1712
+ avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
1713
+ avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
1714
+
1715
+ summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
1716
+
1717
+ refresh_btn.click(
1718
+ fn=compute_summary,
1719
+ inputs=result_states, # list of 4 gr.State components
1720
+ outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1721
+ )
1722
+
1723
+
1724
+
1725
+ if __name__ == "__main__":
1726
+ demo.launch()