LiangLabUMB commited on
Commit
495cad4
Β·
verified Β·
1 Parent(s): c160f31

Upload app_final.py

Browse files
Files changed (1) hide show
  1. app_final.py +1444 -0
app_final.py ADDED
@@ -0,0 +1,1444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import csv
13
+ import joblib
14
+ import os
15
+
16
+ HF_REPO_ID = "myang4218/cellposemodel"
17
+ HF_REPO_ID2 = "LiangLabUMB/viability_model"
18
+ HF_REPO_CPSAM = "mouseland/cellpose-sam"
19
+ MODEL_OPTIONS = {
20
+ "Hemocytometer Model": "hemocytometermodel.npy",
21
+ "General Model": "generalmodel.npy",
22
+ "Cellpose SAMv2": "cpsam_v2",
23
+ }
24
+ MODEL_REPOS = {
25
+ "hemocytometermodel.npy": HF_REPO_ID,
26
+ "generalmodel.npy": HF_REPO_ID,
27
+ "cpsam_v2": HF_REPO_CPSAM,
28
+ }
29
+
30
+
31
+ # Viability classifier is loaded LAZILY (only when viability is first requested).
32
+ # Loading XGBoost / the pickled model at module level initialises a CUDA context
33
+ # in the main process, which conflicts with ZeroGPU's per-call GPU allocation and
34
+ # leaves segmentation requests stuck in the GPU queue. Keeping the main process
35
+ # CUDA-clean until viability is explicitly called avoids this.
36
+ VIABILITY_CLF = None
37
+ VIABILITY_SCALER = None
38
+ _VIABILITY_LOADED = False
39
+ _VIABILITY_ERROR = None
40
+
41
+
42
+ def ensure_viability_loaded():
43
+ """
44
+ Load the XGBoost classifier + scaler on first use, pinned to CPU.
45
+
46
+ XGBoost initialises a CUDA context if it thinks a GPU is available. Once
47
+ that context exists in the main process, every subsequent ZeroGPU fork for
48
+ run_segmentation inherits a dirty CUDA state and hangs in the GPU queue.
49
+ We force the loaded booster onto CPU so it never touches CUDA β€” viability
50
+ inference on ~20 features per cell is trivially fast on CPU anyway.
51
+ """
52
+ global VIABILITY_CLF, VIABILITY_SCALER, _VIABILITY_LOADED, _VIABILITY_ERROR
53
+ if _VIABILITY_LOADED:
54
+ return
55
+ try:
56
+ import xgboost
57
+ _clf_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_clf.pkl")
58
+ _scaler_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_scaler.pkl")
59
+ VIABILITY_CLF = joblib.load(_clf_path)
60
+ VIABILITY_SCALER = joblib.load(_scaler_path)
61
+
62
+ # Force CPU inference so XGBoost never initialises a CUDA context.
63
+ try:
64
+ VIABILITY_CLF.set_params(device="cpu", predictor="cpu_predictor", tree_method="hist")
65
+ except Exception:
66
+ pass
67
+ try:
68
+ booster = VIABILITY_CLF.get_booster()
69
+ booster.set_param({"device": "cpu", "predictor": "cpu_predictor"})
70
+ except Exception:
71
+ pass
72
+
73
+ _VIABILITY_LOADED = True
74
+ print("βœ“ Viability classifier loaded (lazy, CPU-pinned).")
75
+ except Exception as e:
76
+ _VIABILITY_ERROR = str(e)
77
+ print(f"Viability classifier failed to load: {e}")
78
+
79
+ # mobile safe resize limits
80
+ MAX_SIDE = 1024
81
+ MAX_PIXELS = 1024 * 1024
82
+
83
+
84
+ def safe_resize(image_np):
85
+
86
+ h, w = image_np.shape[:2]
87
+ total = h * w
88
+
89
+ if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
90
+ return image_np
91
+
92
+ # compute scale
93
+ scale_side = MAX_SIDE / max(h, w)
94
+ scale_pixels = (MAX_PIXELS / total) ** 0.5
95
+ scale = min(scale_side, scale_pixels)
96
+
97
+ new_w = max(1, int(w * scale))
98
+ new_h = max(1, int(h * scale))
99
+
100
+ return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
101
+
102
+
103
+ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
104
+
105
+ h, w = image_np.shape[:2]
106
+
107
+ # Convert to PIL for drawing
108
+ img_pil = Image.fromarray(image_np)
109
+ draw = ImageDraw.Draw(img_pil, 'RGBA')
110
+
111
+ # Calculate pixel widths from percentages
112
+ left_px = int(w * left_width_pct / 100)
113
+ top_px = int(h * top_width_pct / 100)
114
+
115
+ # Draw overlays for exclusion zones
116
+ if left_px > 0:
117
+ # Left exclusion zone
118
+ draw.rectangle(
119
+ [(0, 0), (left_px, h)],
120
+ fill=(255, 0, 0, 80) # Semi-transparent red
121
+ )
122
+ # border line
123
+ draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
124
+
125
+ if top_px > 0:
126
+ # Top exclusion zone
127
+ draw.rectangle(
128
+ [(0, 0), (w, top_px)],
129
+ fill=(255, 0, 0, 80) # Semi-transparent red
130
+ )
131
+ # border line
132
+ draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
133
+
134
+ return np.array(img_pil)
135
+
136
+
137
+ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
138
+ h, w = masks.shape
139
+
140
+ # Calculate pixel widths from percentages
141
+ left_px = int(w * left_width_pct / 100)
142
+ top_px = int(h * top_width_pct / 100)
143
+
144
+ filtered_masks = masks.copy()
145
+ cell_ids = np.unique(masks)
146
+ cell_ids = cell_ids[cell_ids > 0]
147
+
148
+ excluded_cells = []
149
+ included_cells = []
150
+
151
+ for cell_id in cell_ids:
152
+ cell_mask = (masks == cell_id)
153
+
154
+ # Get cell boundary coordinates
155
+ rows, cols = np.where(cell_mask)
156
+
157
+ # Check if cell touches left exclusion zone
158
+ touches_left = np.any(cols < left_px) if left_px > 0 else False
159
+
160
+ # Check if cell touches top exclusion zone
161
+ touches_top = np.any(rows < top_px) if top_px > 0 else False
162
+
163
+ # Exclude if touching left or top
164
+ if touches_left or touches_top:
165
+ filtered_masks[cell_mask] = 0
166
+ excluded_cells.append(cell_id)
167
+ else:
168
+ included_cells.append(cell_id)
169
+
170
+ # Renumber remaining cells
171
+ unique_ids = np.unique(filtered_masks)
172
+ unique_ids = unique_ids[unique_ids > 0]
173
+
174
+ renumbered_masks = np.zeros_like(filtered_masks)
175
+ for new_id, old_id in enumerate(unique_ids, start=1):
176
+ renumbered_masks[filtered_masks == old_id] = new_id
177
+
178
+ return renumbered_masks, len(excluded_cells), len(included_cells)
179
+
180
+
181
+
182
+ FEATURE_COLS_INFERENCE = [
183
+ "mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b",
184
+ "mean_h", "mean_s", "mean_v", "std_s", "std_v",
185
+ "blue_red_ratio", "blue_green_ratio", "rg_ratio",
186
+ "inner_brightness", "peak_brightness",
187
+ "bright_spot_fraction", "ring_darkness",
188
+ "centre_periphery_ratio", "brightness_std_normalised",
189
+ ]
190
+
191
+
192
+ def classify_cells_by_model(image_np, masks):
193
+
194
+ import numpy as np
195
+ cell_ids = np.unique(masks)
196
+ cell_ids = cell_ids[cell_ids > 0]
197
+ if len(cell_ids) == 0:
198
+ return 0, 0, image_np.copy(), {}
199
+
200
+ features = extract_cell_features(image_np, masks)
201
+ if not features:
202
+ return 0, 0, image_np.copy(), {}
203
+
204
+ import numpy as np
205
+ X = np.array([[f[c] for c in FEATURE_COLS_INFERENCE] for f in features], dtype=np.float32)
206
+
207
+ # replace any NaN/Inf with column median
208
+ for j in range(X.shape[1]):
209
+ bad = ~np.isfinite(X[:, j])
210
+ if bad.any():
211
+ X[bad, j] = float(np.nanmedian(X[:, j]))
212
+
213
+ X_scaled = VIABILITY_SCALER.transform(X)
214
+ predictions = VIABILITY_CLF.predict(X_scaled) # 0=live, 1=dead
215
+
216
+ label_map = {int(f["cell_id"]): int(p) for f, p in zip(features, predictions)}
217
+ overlay = draw_viability_overlay(image_np, masks, label_map)
218
+
219
+ dead = int(sum(predictions))
220
+ alive = int(len(predictions) - dead)
221
+ return dead, alive, overlay, label_map
222
+
223
+
224
+ def draw_viability_overlay(image_np, masks, label_map):
225
+
226
+ overlay = image_np.copy()
227
+ cell_ids = np.unique(masks)
228
+ cell_ids = cell_ids[cell_ids > 0]
229
+ cell_enum = {int(cid): idx + 1 for idx, cid in enumerate(sorted(cell_ids))}
230
+
231
+ for cid in cell_ids:
232
+ cid_int = int(cid)
233
+ label = label_map.get(cid_int, 0)
234
+ color = (220, 50, 50) if label == 1 else (50, 220, 80)
235
+ cell_mask = (masks == cid).astype(np.uint8)
236
+ contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
237
+ cv2.drawContours(overlay, contours, -1, color, thickness=2)
238
+
239
+ ys, xs = np.where(cell_mask)
240
+ if len(ys) > 0:
241
+ cx, cy = int(xs.mean()), int(ys.mean())
242
+ label_str = str(cell_enum[cid_int])
243
+ font = cv2.FONT_HERSHEY_SIMPLEX
244
+ font_scale = 0.35
245
+ thickness = 1
246
+ (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
247
+ cv2.rectangle(overlay,
248
+ (cx - tw//2 - 1, cy - th//2 - 1),
249
+ (cx + tw//2 + 1, cy + th//2 + 1),
250
+ (0, 0, 0), -1)
251
+ cv2.putText(overlay, label_str,
252
+ (cx - tw//2, cy + th//2),
253
+ font, font_scale, color, thickness, cv2.LINE_AA)
254
+ return overlay
255
+
256
+
257
+
258
+
259
+ def measure_confluency(masks, image_np):
260
+ tot_pixels = image_np.shape[0] * image_np.shape[1]
261
+ cell_pixels = np.count_nonzero(masks)
262
+ confluency = cell_pixels / tot_pixels * 100
263
+ return confluency
264
+
265
+ def filter_mask_by_size(masks, minimum_pixels):
266
+ filtered_masks = masks.copy()
267
+ cell_ids = np.unique(masks)
268
+ cell_ids = cell_ids[cell_ids > 0]
269
+
270
+ removed_count = 0
271
+
272
+ for cell_id in cell_ids:
273
+ cell_mask = (masks == cell_id)
274
+ cell_pixels = np.count_nonzero(cell_mask)
275
+ if cell_pixels < minimum_pixels:
276
+ filtered_masks[cell_mask] = 0
277
+ removed_count += 1
278
+
279
+ unique_ids = np.unique(filtered_masks)
280
+ unique_ids = unique_ids[unique_ids > 0]
281
+
282
+ renumbered_masks = np.zeros_like(filtered_masks)
283
+ for new_id, old_id in enumerate(unique_ids, start=1):
284
+ renumbered_masks[filtered_masks == old_id] = new_id
285
+
286
+ return renumbered_masks, removed_count
287
+
288
+
289
+ def filter_mask_by_maxsize(masks, maximum_pixels):
290
+ filtered_masks = masks.copy()
291
+ cell_ids = np.unique(masks)
292
+ cell_ids = cell_ids[cell_ids > 0]
293
+
294
+ removed_count = 0
295
+ for cell_id in cell_ids:
296
+ cell_mask = (masks == cell_id)
297
+ cell_pixels = np.count_nonzero(cell_mask)
298
+ if cell_pixels > maximum_pixels:
299
+ filtered_masks[cell_mask] = 0
300
+ removed_count += 1
301
+
302
+ unique_ids = np.unique(filtered_masks)
303
+ unique_ids = unique_ids[unique_ids > 0]
304
+
305
+ renumbered_masks = np.zeros_like(filtered_masks)
306
+ for new_id, old_id in enumerate(unique_ids, start=1):
307
+ renumbered_masks[filtered_masks == old_id] = new_id
308
+
309
+ return renumbered_masks, removed_count
310
+
311
+
312
+ def rec_min_size(masks, q=25):
313
+ ids = np.unique(masks)
314
+ ids = ids[ids > 0]
315
+ if len(ids) == 0:
316
+ return 0
317
+ sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
318
+ return int(round(np.percentile(sizes, q)))
319
+
320
+
321
+ def apply_polygon_mask(image_pil, points_json):
322
+ """
323
+ Given a PIL image and a JSON string of [[x,y],...] points,
324
+ zero out everything outside the polygon and return a PIL image.
325
+ """
326
+ import json
327
+ if not points_json or points_json.strip() in ("", "[]"):
328
+ return image_pil
329
+ try:
330
+ pts = json.loads(points_json)
331
+ except Exception:
332
+ return image_pil
333
+ if len(pts) < 3:
334
+ return image_pil
335
+
336
+ image_np = np.array(image_pil)
337
+ h, w = image_np.shape[:2]
338
+ poly = np.array(pts, dtype=np.int32)
339
+ poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
340
+ poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
341
+ mask = np.zeros((h, w), dtype=np.uint8)
342
+ cv2.fillPoly(mask, [poly], 255)
343
+ if len(image_np.shape) == 3:
344
+ result = np.where(mask[:, :, np.newaxis] == 255, image_np, 0).astype(np.uint8)
345
+ else:
346
+ result = np.where(mask == 255, image_np, 0).astype(np.uint8)
347
+ return Image.fromarray(result)
348
+
349
+ def warp_polygon_to_square(image_np, points):
350
+ pts = np.array(points, dtype=np.float32)
351
+
352
+ s = pts.sum(axis=1)
353
+ diff = np.diff(pts, axis=1).ravel()
354
+ tl = pts[np.argmin(s)]
355
+ br = pts[np.argmax(s)]
356
+ tr = pts[np.argmin(diff)]
357
+ bl = pts[np.argmax(diff)]
358
+ src = np.array([tl, tr, br, bl], dtype=np.float32)
359
+
360
+ w1 = np.linalg.norm(br-bl)
361
+ w2 = np.linalg.norm(tr-tl)
362
+ h1 = np.linalg.norm(tr-br)
363
+ h2 = np.linalg.norm(tl-bl)
364
+ out_w = int(max(w1, w2))
365
+ out_h = int(max(h1, h2))
366
+
367
+ dst = np.array(
368
+ [[0, 0],
369
+ [out_w - 1, 0],
370
+ [out_w - 1, out_h - 1],
371
+ [0, out_h - 1]],
372
+ dtype=np.float32)
373
+
374
+ M = cv2.getPerspectiveTransform(src, dst)
375
+ warped = cv2.warpPerspective(image_np, M, (out_w, out_h))
376
+ return warped
377
+
378
+
379
+ def toggle_stereological_mode(use_stereology):
380
+ return gr.update(visible=use_stereology)
381
+
382
+
383
+ def update_exclusion_preview(image, left_width, top_width):
384
+ if image is None:
385
+ return None
386
+
387
+ image_np = np.array(image)
388
+ overlay = draw_exclusion_overlay(image_np, left_width, top_width)
389
+ return Image.fromarray(overlay)
390
+
391
+
392
+ # Patch segmentation
393
+
394
+ PATCH_SIZE = 512 # target patch side length
395
+ PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
396
+ MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
397
+
398
+
399
+ def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
400
+ """
401
+ Split image into overlapping patches.
402
+ Returns list of (patch_np, row_start, col_start) tuples.
403
+ """
404
+ h, w = image_np.shape[:2]
405
+ patches = []
406
+ row = 0
407
+ while row < h:
408
+ row_end = min(row + patch_size, h)
409
+ col = 0
410
+ while col < w:
411
+ col_end = min(col + patch_size, w)
412
+ patch = image_np[row:row_end, col:col_end]
413
+ patches.append((patch, row, col))
414
+ if col_end == w:
415
+ break
416
+ col += patch_size - overlap
417
+ if row_end == h:
418
+ break
419
+ row += patch_size - overlap
420
+ return patches
421
+
422
+
423
+ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
424
+ """
425
+ Stitch per-patch masks into a single full-image mask.
426
+
427
+ Strategy:
428
+ - Each patch gets a unique ID offset so cell IDs never collide.
429
+ - Patches are pasted into the canvas using a priority canvas that
430
+ gives interior pixels precedence over overlap-border pixels.
431
+ - After pasting, cells whose centroids fall in the overlap zone
432
+ of two adjacent patches are deduplicated: if two cells from
433
+ different patches share >50% IoU they are the same cell β€” keep
434
+ the one whose centroid is furthest from a patch edge.
435
+ """
436
+ full_mask = np.zeros((full_h, full_w), dtype=np.int32)
437
+ # track which patch_idx owns each pixel (used for overlap resolution)
438
+ owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
439
+ # distance-to-nearest-edge for the owning patch (higher = more central)
440
+ priority = np.zeros((full_h, full_w), dtype=np.float32)
441
+
442
+ id_offset = 0
443
+ patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
444
+
445
+ for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
446
+ ph, pw = mask_patch.shape
447
+ # offset all non-zero IDs so they're globally unique
448
+ shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
449
+
450
+ # compute per-pixel priority = min distance to any patch edge
451
+ rows_idx = np.arange(ph)
452
+ cols_idx = np.arange(pw)
453
+ dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
454
+ dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
455
+ pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
456
+
457
+ roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
458
+ roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
459
+ roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
460
+
461
+ # where this patch has higher priority, overwrite
462
+ better = pri_patch > roi_pri
463
+ roi_full [better] = shifted [better]
464
+ roi_owner[better] = patch_idx
465
+ roi_pri [better] = pri_patch [better]
466
+
467
+ max_id = int(mask_patch.max())
468
+ patch_meta.append((id_offset, row_start, col_start, ph, pw))
469
+ id_offset += max_id + 1
470
+
471
+ # --- Renumber to compact sequential IDs ---
472
+ unique_ids = np.unique(full_mask)
473
+ unique_ids = unique_ids[unique_ids > 0]
474
+ renumbered = np.zeros_like(full_mask)
475
+ for new_id, old_id in enumerate(unique_ids, start=1):
476
+ renumbered[full_mask == old_id] = new_id
477
+
478
+ return renumbered
479
+
480
+
481
+ def run_segmentation_patched(image_np, model_filename):
482
+ """
483
+ Split image into overlapping patches, run Cellpose sequentially on each,
484
+ then stitch back into a single full-resolution mask.
485
+
486
+ NOTE: ThreadPoolExecutor was removed because ZeroGPU allocates the GPU
487
+ only to the main thread. Worker threads spawned by ThreadPoolExecutor run
488
+ outside the GPU context, causing a 3-minute timeout waiting for a GPU
489
+ grant that never arrives for the worker threads.
490
+ Sequential patching within the @spaces.GPU context is correct and fast.
491
+ """
492
+ h, w = image_np.shape[:2]
493
+ repo = MODEL_REPOS.get(model_filename, HF_REPO_ID)
494
+ # hf_hub_download caches the weights file on disk, so this only hits the
495
+ # network once β€” subsequent calls read from the local cache.
496
+ model_path = hf_hub_download(repo_id=repo, filename=model_filename)
497
+
498
+ # IMPORTANT: build the CellposeModel fresh on every call. ZeroGPU allocates
499
+ # a DIFFERENT physical GPU for each @spaces.GPU invocation, so a model cached
500
+ # from a previous call holds a CUDA context bound to a now-deallocated GPU.
501
+ # Reusing it makes the next request hang in the GPU queue. Constructing the
502
+ # model is cheap because the weights are already on local disk.
503
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
504
+
505
+ # Small images: no benefit from patching
506
+ if max(h, w) <= MIN_PATCH_DIM * 2:
507
+ mask, _, _ = model.eval(image_np, diameter=None)
508
+ return mask, 1
509
+
510
+ patches = _split_patches(image_np)
511
+ n_patches = len(patches)
512
+
513
+ patch_results = []
514
+ for patch, row_start, col_start in patches:
515
+ mask_patch, _, _ = model.eval(patch, diameter=None)
516
+ patch_results.append((mask_patch, row_start, col_start))
517
+
518
+ full_mask = _merge_patch_masks(patch_results, h, w)
519
+ return full_mask, n_patches
520
+
521
+
522
+ @spaces.GPU
523
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
524
+ use_min_filter, use_max_filter,
525
+ use_stereology, left_exclusion, top_exclusion,
526
+ crop_points=None):
527
+ image_np = np.array(image)
528
+ image_np = safe_resize(image_np)
529
+
530
+ raw_image_np = image_np.copy()
531
+
532
+ # Apply polygon crop mask if the user drew one (need β‰₯3 points for a polygon)
533
+ if crop_points and len(crop_points) >= 3:
534
+ import json
535
+ pts_json = json.dumps(crop_points)
536
+ image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
537
+ image_np = np.array(image_pil_masked)
538
+
539
+ if len(crop_points) == 4:
540
+ image_np = warp_polygon_to_square(image_np, crop_points)
541
+
542
+
543
+ try:
544
+ model_filename = MODEL_OPTIONS[model_choice]
545
+
546
+ # Process image format to RGB
547
+ if len(image_np.shape) == 2:
548
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
549
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
550
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
551
+ else:
552
+ processed_image_np = image_np
553
+
554
+ # Run patch-parallel Cellpose segmentation
555
+ masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
556
+
557
+ ids = np.unique(masks_raw)
558
+ ids = ids[ids > 0]
559
+
560
+ sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
561
+
562
+ print("num_cells:", len(ids))
563
+ print("mean:", sizes.mean() if len(sizes) > 0 else 0)
564
+ print("median:", np.median(sizes) if len(sizes) > 0 else 0)
565
+ print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
566
+ print("max:", sizes.max() if len(sizes) > 0 else 0)
567
+
568
+ # Compute recommendation from RAW masks (always shown, never auto-applied)
569
+ recommend_min = rec_min_size(masks_raw)
570
+
571
+ # Apply filters only if their checkboxes are enabled
572
+ masks = masks_raw.copy()
573
+ removed_small = 0
574
+ removed_large = 0
575
+
576
+ if use_min_filter and int(min_cell_size) > 0:
577
+ masks, removed_small = filter_mask_by_size(masks, int(min_cell_size))
578
+
579
+ if use_max_filter and max_cell_size > 0:
580
+ masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
581
+
582
+ # Apply stereological exclusion if enabled
583
+ excluded_count = 0
584
+ if use_stereology:
585
+ masks, excluded_count, included_count = apply_stereological_exclusion(
586
+ masks, left_exclusion, top_exclusion
587
+ )
588
+
589
+ filter_msg = ""
590
+ if removed_small:
591
+ filter_msg += f"Removed {removed_small} small objects (< {int(min_cell_size)} pixels).\n"
592
+ if removed_large:
593
+ filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
594
+ if use_stereology and excluded_count > 0:
595
+ filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
596
+
597
+ cell_count = len(np.unique(masks)) - 1
598
+ confluency = measure_confluency(masks, processed_image_np)
599
+
600
+ # Create a basic segmentation overlay (without viability)
601
+ segmentation_overlay = processed_image_np.copy().astype(np.float32)
602
+ if masks.max() > 0:
603
+ np.random.seed(42) # For consistent random colors
604
+ colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
605
+ colors[0] = [0, 0, 0]
606
+ colored_mask = colors[masks]
607
+ alpha = 0.4
608
+ segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
609
+ segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
610
+
611
+ # Add exclusion zone overlay if stereology is enabled
612
+ if use_stereology:
613
+ segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
614
+
615
+ info_msg = ""
616
+ if filter_msg:
617
+ info_msg += filter_msg
618
+ info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
619
+ info_msg += f"Confluency: {confluency:.1f}%\n"
620
+ info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
621
+ if use_stereology:
622
+ info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
623
+ info_msg += "Now run the viability classification model for viability assessment."
624
+
625
+ return (
626
+ cell_count,
627
+ Image.fromarray(segmentation_overlay),
628
+ info_msg,
629
+ gr.update(visible=True),
630
+ pack_array(masks),
631
+ pack_array(processed_image_np),
632
+ confluency,
633
+ f"Recommended minimum: **{recommend_min} px** (25th percentile of detected cell sizes)",
634
+ pack_array(raw_image_np),
635
+ )
636
+
637
+ except Exception as e:
638
+ import traceback
639
+ traceback.print_exc()
640
+ return (
641
+ 0,
642
+ None,
643
+ f"Error during segmentation: {str(e)}",
644
+ gr.update(visible=False),
645
+ None,
646
+ None,
647
+ 0.0,
648
+ "",
649
+ None,
650
+ )
651
+
652
+
653
+ def run_viability(stored_masks, stored_image_np):
654
+ if stored_masks is None or stored_image_np is None:
655
+ return None, 0, 0, 0.0, "Please run segmentation first.", {}
656
+
657
+ # Lazy-load the classifier the first time viability is requested
658
+ ensure_viability_loaded()
659
+ if VIABILITY_CLF is None:
660
+ err = _VIABILITY_ERROR or "unknown error"
661
+ return None, 0, 0, 0.0, f"Viability model failed to load: {err}", {}
662
+
663
+ masks = unpack_array(stored_masks)
664
+ image_np = unpack_array(stored_image_np)
665
+
666
+ try:
667
+ dead, alive, overlay_np, label_map = classify_cells_by_model(image_np, masks)
668
+ total = alive + dead
669
+ viab_pct = (alive / total * 100) if total > 0 else 0.0
670
+ confluency = measure_confluency(masks, image_np)
671
+ info_msg = f"Total cells: {total}\nLive (green): {alive}\nDead (red): {dead}\n"
672
+ info_msg += f"Viability: {viab_pct:.1f}%\nConfluency: {confluency:.1f}%"
673
+ return Image.fromarray(overlay_np), alive, dead, viab_pct, info_msg, label_map
674
+ except Exception as e:
675
+ import traceback; traceback.print_exc()
676
+ return None, 0, 0, 0.0, f"Error: {str(e)}", {}
677
+
678
+
679
+ def pack_array(arr):
680
+ """
681
+ Serialise a numpy array to a base64 string for gr.State storage.
682
+ Uses numpy .npy format (preserves int32 exactly, no 255 truncation)
683
+ encoded as base64 so it is a plain Python string β€” safe for ZeroGPU
684
+ state serialisation which cannot handle raw bytes objects.
685
+ """
686
+ buf = io.BytesIO()
687
+ np.save(buf, arr)
688
+ return base64.b64encode(buf.getvalue()).decode("ascii")
689
+
690
+
691
+ def unpack_array(data):
692
+ buf = io.BytesIO(base64.b64decode(data))
693
+ return np.load(buf, allow_pickle=False)
694
+
695
+
696
+ def save_tab_result(cell_count, confluency, viab_percent):
697
+ """Package per-tab results into a dict for Tab 5 summary."""
698
+ return {
699
+ "cell_count": float(cell_count) if cell_count is not None else None,
700
+ "confluency": float(confluency) if confluency is not None else None,
701
+ "viab_percent": float(viab_percent) if viab_percent is not None else None,
702
+ }
703
+
704
+
705
+ def compute_summary(r1, r2, r3, r4):
706
+ """Average cell count, confluency, and viability across tabs that have data."""
707
+ all_results = [r1, r2, r3, r4]
708
+ valid = [(i + 1, r) for i, r in enumerate(all_results)
709
+ if r is not None and r.get("cell_count") is not None]
710
+
711
+ if not valid:
712
+ return (
713
+ 0.0, 0.0, 0.0,
714
+ "No data yet β€” run segmentation in at least one tab, then click Refresh Summary."
715
+ )
716
+
717
+ avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
718
+ avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
719
+ avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
720
+
721
+ lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
722
+ f"{r['confluency']:.1f}% confluency | "
723
+ f"{r['viab_percent']:.1f}% viability"
724
+ for tab_num, r in valid]
725
+ lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
726
+ lines.append(f" Cell count: {avg_count:.1f}")
727
+ lines.append(f" Confluency: {avg_conf:.1f}%")
728
+ lines.append(f" Viability: {avg_viab:.1f}%")
729
+
730
+ return avg_count, avg_conf, avg_viab, "\n".join(lines)
731
+
732
+
733
+
734
+ # Training data export β€” feature extraction per cell
735
+
736
+
737
+ def extract_cell_features(image_np, masks):
738
+
739
+ if len(image_np.shape) == 2:
740
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
741
+ elif image_np.shape[2] == 4:
742
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
743
+
744
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV).astype(np.float32)
745
+
746
+ h_img, w_img = image_np.shape[:2]
747
+ grid_y, grid_x = np.mgrid[:h_img, :w_img]
748
+
749
+ cell_ids = np.unique(masks)
750
+ cell_ids = cell_ids[cell_ids > 0]
751
+ rows = []
752
+
753
+ for cid in cell_ids:
754
+ cell_mask = (masks == cid)
755
+ pixels_rgb = image_np[cell_mask].astype(np.float32)
756
+ pixels_hsv = hsv[cell_mask]
757
+
758
+ r, g, b = pixels_rgb[:, 0], pixels_rgb[:, 1], pixels_rgb[:, 2]
759
+ h, s, v = pixels_hsv[:, 0], pixels_hsv[:, 1], pixels_hsv[:, 2]
760
+
761
+ eps = 1e-6
762
+ blue_red_ratio = b.mean() / (r.mean() + eps)
763
+ blue_green_ratio = b.mean() / (g.mean() + eps)
764
+ rg_ratio = r.mean() / (g.mean() + eps)
765
+
766
+ area_px = int(cell_mask.sum())
767
+ contours, _ = cv2.findContours(
768
+ cell_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
769
+ )
770
+ perimeter = cv2.arcLength(contours[0], True) if contours else 1.0
771
+ circularity = (4 * np.pi * area_px / (perimeter ** 2 + eps)) if perimeter > 0 else 0.0
772
+
773
+ ys_cell = grid_y[cell_mask].astype(np.float32)
774
+ xs_cell = grid_x[cell_mask].astype(np.float32)
775
+ centroid_y = ys_cell.mean()
776
+ centroid_x = xs_cell.mean()
777
+
778
+ cell_radius = np.sqrt(area_px / np.pi) + eps
779
+ dist_norm = np.sqrt((xs_cell - centroid_x)**2 + (ys_cell - centroid_y)**2) / cell_radius
780
+
781
+ v_all = hsv[:, :, 2][cell_mask]
782
+
783
+ # Tight inner core (15% radius) β€” captures specular highlight spot only
784
+ inner_mask = dist_norm < 0.15
785
+ # Membrane ring zone (20-60%) β€” dark navy ring on live cells
786
+ ring_mask = (dist_norm >= 0.20) & (dist_norm <= 0.60)
787
+ # Outer zone (>60%) β€” denominator for centre ratio
788
+ outer_mask = dist_norm > 0.60
789
+
790
+ inner_brightness = float(v_all[inner_mask].mean()) if inner_mask.any() else float(v.mean())
791
+ ring_brightness = float(v_all[ring_mask].mean()) if ring_mask.any() else float(v.mean())
792
+ outer_brightness = float(v_all[outer_mask].mean()) if outer_mask.any() else float(v.mean())
793
+
794
+ # Peak V β€” specular spot is just a few pixels so mean dilutes it
795
+ peak_brightness = float(v_all.max())
796
+
797
+ # Fraction of cell pixels with V > 200 (specular highlight region)
798
+ bright_spot_fraction = float((v_all > 200).sum()) / (len(v_all) + eps)
799
+
800
+ # Ring darkness: ratio of ring zone to outer zone brightness
801
+ # Live: ring << outer (dark membrane ring) -> ratio < 1
802
+ # Dead: uniform blob -> ratio ~ 1
803
+ ring_darkness = ring_brightness / (outer_brightness + eps)
804
+
805
+ centre_periphery_ratio = inner_brightness / (outer_brightness + eps)
806
+
807
+ brightness_std_normalised = float(v.std()) / (float(v.mean()) + eps)
808
+
809
+ rows.append({
810
+ "cell_id": int(cid),
811
+ "mean_r": float(r.mean()),
812
+ "mean_g": float(g.mean()),
813
+ "mean_b": float(b.mean()),
814
+ "std_r": float(r.std()),
815
+ "std_g": float(g.std()),
816
+ "std_b": float(b.std()),
817
+ "mean_h": float(h.mean()),
818
+ "mean_s": float(s.mean()),
819
+ "mean_v": float(v.mean()),
820
+ "std_s": float(s.std()),
821
+ "std_v": float(v.std()),
822
+ "blue_red_ratio": round(blue_red_ratio, 5),
823
+ "blue_green_ratio": round(blue_green_ratio, 5),
824
+ "rg_ratio": round(rg_ratio, 5),
825
+ "area_px": area_px,
826
+ "circularity": round(float(circularity), 5),
827
+ "inner_brightness": round(inner_brightness, 3),
828
+ "peak_brightness": round(peak_brightness, 3),
829
+ "bright_spot_fraction": round(bright_spot_fraction, 6),
830
+ "ring_darkness": round(ring_darkness, 5),
831
+ "centre_periphery_ratio": round(centre_periphery_ratio, 5),
832
+ "brightness_std_normalised": round(brightness_std_normalised, 5),
833
+ })
834
+
835
+ return rows
836
+
837
+ def attach_viability_labels(cell_features, masks, image_np, label_map=None):
838
+ """
839
+ Attach model predictions (from label_map) to each feature dict.
840
+ label_map: {cell_id: 0=live, 1=dead} from classify_cells_by_model.
841
+ If label_map is None, defaults all labels to 0 (live).
842
+ """
843
+ if not cell_features:
844
+ return []
845
+ labelled = []
846
+ for feat in cell_features:
847
+ row = dict(feat)
848
+ cid = int(feat["cell_id"])
849
+ row["label"] = int(label_map.get(cid, 0)) if label_map else 0
850
+ row["corrected"] = False
851
+ labelled.append(row)
852
+ return labelled
853
+
854
+
855
+ def export_cell_data_csv(cell_data):
856
+ """Write cell_data list-of-dicts to a temp CSV and return the file path."""
857
+ if not cell_data:
858
+ return None
859
+ tmp = tempfile.NamedTemporaryFile(
860
+ mode="w", suffix=".csv", delete=False, newline=""
861
+ )
862
+ # Union of all keys across rows so any late-added keys (e.g. "corrected") are included
863
+ fieldnames = list(dict.fromkeys(k for row in cell_data for k in row.keys()))
864
+ writer = csv.DictWriter(tmp, fieldnames=fieldnames, extrasaction="ignore")
865
+ writer.writeheader()
866
+ writer.writerows(cell_data)
867
+ tmp.close()
868
+ return tmp.name
869
+
870
+
871
+ def prepare_export(stored_masks, stored_image, threshold_bias):
872
+ """
873
+ Called by the Export button. Unpacks state, extracts features,
874
+ attaches labels, writes CSV, returns (path, status_message).
875
+ """
876
+ if stored_masks is None or stored_image is None:
877
+ return None, "Run segmentation first before exporting."
878
+
879
+ masks = unpack_array(stored_masks)
880
+ image_np = unpack_array(stored_image)
881
+
882
+ features = extract_cell_features(image_np, masks)
883
+ if not features:
884
+ return None, "No cells found to export."
885
+
886
+ labelled = attach_viability_labels(features, masks, image_np, threshold_bias)
887
+ path = export_cell_data_csv(labelled)
888
+
889
+ n = len(labelled)
890
+ dead = sum(1 for r in labelled if r["label"] == 1)
891
+ alive = n - dead
892
+ msg = (f"Exported {n} cells ({alive} live, {dead} dead) β€” "
893
+ f"threshold bias={threshold_bias:+d}.\n"
894
+ f"Columns: {', '.join(list(labelled[0].keys())[:6])}… "
895
+ f"({len(labelled[0])} total).")
896
+ return path, msg
897
+
898
+
899
+
900
+ # Tab builder
901
+
902
+ def draw_polygon_overlay(image_pil, points):
903
+ """
904
+ Draw numbered vertex dots and polygon edges onto a copy of image_pil.
905
+ points: list of (x, y) tuples in original image pixel space.
906
+ Returns a new PIL image.
907
+ """
908
+ img = image_pil.copy().convert("RGBA")
909
+ overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
910
+ draw = ImageDraw.Draw(overlay)
911
+
912
+ if len(points) >= 2:
913
+ # Draw edges
914
+ for i in range(len(points) - 1):
915
+ draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
916
+ if len(points) == 4:
917
+ draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
918
+ # Semi-transparent fill
919
+ draw.polygon(points, fill=(74, 170, 255, 50))
920
+
921
+ # Draw vertex dots + numbers
922
+ r = max(8, min(img.width, img.height) // 60)
923
+ for i, (x, y) in enumerate(points):
924
+ draw.ellipse([x - r, y - r, x + r, y + r],
925
+ fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
926
+ draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
927
+
928
+ combined = Image.alpha_composite(img, overlay)
929
+ return combined.convert("RGB")
930
+
931
+
932
+ def add_crop_point(image_pil, points, evt: gr.SelectData):
933
+ """
934
+ Called by gr.Image .select(). Appends the clicked coordinate,
935
+ redraws the overlay, returns (updated_image, updated_points).
936
+ Ignores clicks once 4 points are set.
937
+ """
938
+ if image_pil is None:
939
+ return image_pil, points
940
+ if points is None:
941
+ points = []
942
+ if len(points) >= 4:
943
+ return draw_polygon_overlay(image_pil, points), points
944
+
945
+ x, y = int(evt.index[0]), int(evt.index[1])
946
+ new_points = points + [(x, y)]
947
+ return draw_polygon_overlay(image_pil, new_points), new_points
948
+
949
+
950
+ def clear_crop_points(image_pil):
951
+ """Reset polygon β€” return original image with no overlay and empty points."""
952
+ return image_pil, []
953
+
954
+
955
+
956
+
957
+
958
+ # Label correction grid
959
+
960
+ THUMB_SIZE = 80
961
+ GRID_COLS = 10
962
+ BORDER = 4
963
+ LABEL_H = 16
964
+
965
+ def _crop_cell_thumb(image_np, masks, cid):
966
+ """
967
+ Return a tight square crop of the cell, padded to THUMB_SIZE Γ— THUMB_SIZE.
968
+ """
969
+ ys, xs = np.where(masks == cid)
970
+ if len(ys) == 0:
971
+ return Image.fromarray(np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8))
972
+
973
+ y0, y1 = ys.min(), ys.max() + 1
974
+ x0, x1 = xs.min(), xs.max() + 1
975
+
976
+ # add a small context border around the tight bounding box
977
+ pad = max(4, int(max(y1 - y0, x1 - x0) * 0.15))
978
+ h, w = image_np.shape[:2]
979
+ y0c = max(0, y0 - pad)
980
+ y1c = min(h, y1 + pad)
981
+ x0c = max(0, x0 - pad)
982
+ x1c = min(w, x1 + pad)
983
+
984
+ crop = image_np[y0c:y1c, x0c:x1c].copy()
985
+
986
+ # dim pixels that don't belong to this cell
987
+ dim_mask = (masks[y0c:y1c, x0c:x1c] != cid)
988
+ crop[dim_mask] = (crop[dim_mask] * 0.3).astype(np.uint8)
989
+
990
+ pil = Image.fromarray(crop).resize((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
991
+ return pil
992
+
993
+
994
+ def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
995
+
996
+ if not labelled_features:
997
+ placeholder = Image.fromarray(
998
+ np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
999
+ )
1000
+ return placeholder
1001
+
1002
+ thumb_src = raw_image_np if raw_image_np is not None else image_np
1003
+
1004
+ n = len(labelled_features)
1005
+ n_cols = GRID_COLS
1006
+ n_rows = (n + n_cols - 1) // n_cols
1007
+
1008
+ cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1009
+ cell_w = THUMB_SIZE + 2 * BORDER
1010
+
1011
+ grid_w = n_cols * cell_w
1012
+ grid_h = n_rows * cell_h
1013
+
1014
+ grid = Image.new("RGB", (grid_w, grid_h), (30, 30, 30))
1015
+ draw = ImageDraw.Draw(grid)
1016
+
1017
+ for idx, feat in enumerate(labelled_features):
1018
+ cid = feat["cell_id"]
1019
+ label = feat["label"] # 0=live, 1=dead (may have been corrected)
1020
+ color = (220, 50, 50) if label == 1 else (50, 200, 80)
1021
+
1022
+ thumb = _crop_cell_thumb(thumb_src, masks, cid)
1023
+
1024
+ col = idx % n_cols
1025
+ row = idx // n_cols
1026
+ x0 = col * cell_w
1027
+ y0 = row * cell_h
1028
+
1029
+ # coloured border rectangle
1030
+ draw.rectangle([x0, y0, x0 + cell_w - 1, y0 + cell_h - 1], outline=color, width=BORDER)
1031
+
1032
+ # paste thumbnail inside border
1033
+ grid.paste(thumb, (x0 + BORDER, y0 + BORDER))
1034
+
1035
+ # small cell-id label strip
1036
+ strip_y = y0 + BORDER + THUMB_SIZE
1037
+ draw.rectangle([x0, strip_y, x0 + cell_w - 1, y0 + cell_h - 1],
1038
+ fill=(20, 20, 20))
1039
+ draw.text((x0 + BORDER + 2, strip_y + 1),
1040
+ f"#{cid} {'D' if label == 1 else 'L'}",
1041
+ fill=color)
1042
+
1043
+ return grid
1044
+
1045
+
1046
+ def toggle_cell_label(labelled_features, image_np, masks, raw_image_np, evt: gr.SelectData):
1047
+ """
1048
+ Called when user taps the correction grid image.
1049
+ Maps the tap pixel coordinate back to which thumbnail was tapped,
1050
+ flips that cell's label, rebuilds and returns the updated grid.
1051
+ """
1052
+ if not labelled_features or image_np is None:
1053
+ return build_correction_grid(image_np, masks, labelled_features), labelled_features
1054
+
1055
+ cell_w = THUMB_SIZE + 2 * BORDER
1056
+ cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1057
+
1058
+ px, py = int(evt.index[0]), int(evt.index[1])
1059
+ col = px // cell_w
1060
+ row = py // cell_h
1061
+ idx = row * GRID_COLS + col
1062
+
1063
+ if idx < 0 or idx >= len(labelled_features):
1064
+ return build_correction_grid(image_np, masks, labelled_features, raw_image_np), labelled_features
1065
+
1066
+ # Flip the label
1067
+ updated = list(labelled_features) # shallow copy of list
1068
+ cell = dict(updated[idx]) # copy the dict so we don't mutate in place
1069
+ cell["label"] = 1 - cell["label"] # 0β†’1 or 1β†’0
1070
+ cell["corrected"] = True
1071
+ updated[idx] = cell
1072
+
1073
+ grid = build_correction_grid(image_np, masks, updated, raw_image_np)
1074
+ n_corrected = sum(1 for f in updated if f.get("corrected"))
1075
+ return grid, updated, f"Tapped cell #{cell['cell_id']} β†’ {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1076
+
1077
+
1078
+ def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1079
+ """Export CSV using labelled_features with any manual corrections applied."""
1080
+ if stored_masks is None or stored_image is None:
1081
+ return None, "Run segmentation first before exporting."
1082
+ masks = unpack_array(stored_masks)
1083
+ image_np = unpack_array(stored_image)
1084
+ if not labelled_features:
1085
+ features = extract_cell_features(image_np, masks)
1086
+ labelled_features = attach_viability_labels(features, masks, image_np, label_map)
1087
+ if not labelled_features:
1088
+ return None, "No cells found to export."
1089
+ path = export_cell_data_csv(labelled_features)
1090
+ n = len(labelled_features)
1091
+ dead = sum(1 for r in labelled_features if r["label"] == 1)
1092
+ alive = n - dead
1093
+ corrected = sum(1 for r in labelled_features if r.get("corrected"))
1094
+ msg = (f"Exported {n} cells ({alive} live, {dead} dead). "
1095
+ f"{corrected} label(s) manually corrected.")
1096
+ return path, msg
1097
+
1098
+ def build_tab(tab_index, masks_state, image_state, result_state):
1099
+ with gr.Tab(f"Tab {tab_index}"):
1100
+ gr.Markdown("Run segmentation")
1101
+
1102
+ # Per-tab state: list of (x,y) crop polygon points
1103
+ crop_points_state = gr.State(value=[])
1104
+ # Clean copy of the uploaded image (no polygon drawn on it)
1105
+ base_image_state = gr.State(value=None)
1106
+ #raw image state
1107
+ raw_image_state = gr.State(value=None)
1108
+
1109
+ with gr.Row():
1110
+ with gr.Column():
1111
+ img_input = gr.Image(
1112
+ type="pil",
1113
+ label="Upload image",
1114
+ image_mode="RGB",
1115
+ height=512
1116
+ )
1117
+
1118
+ gr.Markdown(
1119
+ "### Crop region (optional)\n"
1120
+ "Click/tap up to **4 points** on the image below to define the region "
1121
+ "to segment. The polygon will be drawn as you click. "
1122
+ "Leave empty to segment the full image."
1123
+ )
1124
+
1125
+ crop_display = gr.Image(
1126
+ type="pil",
1127
+ label="Click to set crop vertices (up to 4)",
1128
+ interactive=True,
1129
+ height=400,
1130
+ )
1131
+
1132
+ crop_status = gr.Markdown("*Upload an image to enable cropping*")
1133
+
1134
+ clear_crop_btn = gr.Button("βœ• Clear crop points", size="sm")
1135
+
1136
+ model_dropdown = gr.Dropdown(
1137
+ choices=list(MODEL_OPTIONS.keys()),
1138
+ label="Select Model",
1139
+ value="Hemocytometer Model"
1140
+ )
1141
+
1142
+ gr.Markdown("### Size Filters")
1143
+
1144
+ use_min_filter = gr.Checkbox(
1145
+ label="Enable minimum size filter",
1146
+ value=False,
1147
+ info="Remove objects smaller than the threshold below"
1148
+ )
1149
+ min_size_slider = gr.Slider(
1150
+ minimum=0,
1151
+ maximum=500,
1152
+ value=0,
1153
+ step=10,
1154
+ label="Minimum Cell Size (pixels)",
1155
+ )
1156
+ min_size_recommendation = gr.Markdown(
1157
+ value="*Run segmentation to see recommended minimum*",
1158
+ )
1159
+
1160
+ use_max_filter = gr.Checkbox(
1161
+ label="Enable maximum size filter",
1162
+ value=False,
1163
+ info="Remove objects larger than the threshold below"
1164
+ )
1165
+ max_size_slider = gr.Slider(
1166
+ minimum=0,
1167
+ maximum=10000,
1168
+ value=10000,
1169
+ step=10,
1170
+ label="Maximum Cell Size (pixels)",
1171
+ )
1172
+
1173
+ gr.Markdown("### Stereological Counting")
1174
+ use_stereo = gr.Checkbox(
1175
+ label="Enable Stereological Counting",
1176
+ value=False,
1177
+ info="Use unbiased stereological rules for cell counting"
1178
+ )
1179
+
1180
+ with gr.Group(visible=False) as stereo_controls:
1181
+ gr.Markdown("""
1182
+ **Stereological Counting Rules:**
1183
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1184
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
1185
+ - This provides unbiased counting for quantification
1186
+ """)
1187
+
1188
+ excl_preview = gr.Image(
1189
+ type="pil",
1190
+ label="Exclusion Zone Preview (Red = Excluded)",
1191
+ height=500
1192
+ )
1193
+
1194
+ left_excl = gr.Slider(
1195
+ minimum=0,
1196
+ maximum=50,
1197
+ value=10,
1198
+ step=1,
1199
+ label="Left Exclusion Width (%)",
1200
+ info="Width of left exclusion zone"
1201
+ )
1202
+
1203
+ top_excl = gr.Slider(
1204
+ minimum=0,
1205
+ maximum=50,
1206
+ value=10,
1207
+ step=1,
1208
+ label="Top Exclusion Width (%)",
1209
+ info="Width of top exclusion zone"
1210
+ )
1211
+
1212
+ segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
1213
+
1214
+ with gr.Column():
1215
+ cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
1216
+ confluency_out = gr.Number(label="Confluency (%)", precision=1)
1217
+ overlay_out = gr.Image(type="pil", label="Segmentation Result")
1218
+ info_out = gr.Textbox(label="Processing Info", lines=4)
1219
+
1220
+ with gr.Group(visible=False) as viability_section:
1221
+ gr.Markdown("### Viability Assessment (Trypan Blue)")
1222
+
1223
+ viab_run_btn = gr.Button("Run Viability Analysis", variant="primary")
1224
+
1225
+ with gr.Row():
1226
+ live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
1227
+ dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
1228
+
1229
+ viab_overlay = gr.Image(type="pil", label="Viability (Green=Live Β· Red=Dead)")
1230
+ viab_percent_out = gr.Number(label="Viability (%)", precision=1)
1231
+ viab_info = gr.Textbox(label="Analysis Results", lines=4)
1232
+
1233
+ gr.Markdown("### Label Correction & Export")
1234
+ gr.Markdown(
1235
+ "After running viability, click **Build correction grid** to review every cell. "
1236
+ "**Green border = Live, Red border = Dead** (model predictions). "
1237
+ "Tap any thumbnail to flip its label β€” the counts and overlay update instantly. "
1238
+ "Export the corrected CSV for retraining."
1239
+ )
1240
+
1241
+ build_grid_btn = gr.Button("πŸ”² Build correction grid", variant="secondary")
1242
+ labelled_state = gr.State(value=[])
1243
+ label_map_state = gr.State(value={})
1244
+
1245
+ correction_grid = gr.Image(
1246
+ type="pil",
1247
+ label="Tap a cell to flip its label (green=live Β· red=dead)",
1248
+ interactive=True,
1249
+ visible=False,
1250
+ )
1251
+ correction_status = gr.Markdown(visible=False)
1252
+
1253
+ with gr.Row():
1254
+ export_btn = gr.Button("⬇️ Export corrected CSV", variant="secondary")
1255
+ export_info = gr.Textbox(label="Export status", lines=2, interactive=False)
1256
+ export_file = gr.File(label="Download CSV", visible=False)
1257
+
1258
+ # ---- Event handlers ------------------------------------------------
1259
+
1260
+ use_stereo.change(
1261
+ fn=toggle_stereological_mode,
1262
+ inputs=[use_stereo],
1263
+ outputs=[stereo_controls]
1264
+ )
1265
+
1266
+ def on_image_upload(img):
1267
+ if img is None:
1268
+ return None, None, "*Upload an image to enable cropping*"
1269
+ return img, img, "*Image loaded β€” click up to 4 points to define crop region*"
1270
+
1271
+ img_input.change(
1272
+ fn=on_image_upload,
1273
+ inputs=[img_input],
1274
+ outputs=[crop_display, base_image_state, crop_status]
1275
+ ).then(fn=lambda: [], outputs=[crop_points_state])
1276
+
1277
+ img_input.change(fn=update_exclusion_preview,
1278
+ inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1279
+ left_excl.change(fn=update_exclusion_preview,
1280
+ inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1281
+ top_excl.change(fn=update_exclusion_preview,
1282
+ inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1283
+
1284
+ def on_crop_click(base_img, points, evt: gr.SelectData):
1285
+ updated_img, updated_pts = add_crop_point(base_img, points, evt)
1286
+ n = len(updated_pts)
1287
+ status = (f"*{n} / 4 points set β€” keep clicking*" if n < 4
1288
+ else "*4 points set βœ“ β€” click **βœ• Clear** to redo, or run segmentation*")
1289
+ return updated_img, updated_pts, status
1290
+
1291
+ crop_display.select(fn=on_crop_click,
1292
+ inputs=[base_image_state, crop_points_state],
1293
+ outputs=[crop_display, crop_points_state, crop_status])
1294
+
1295
+ def on_clear_crop(base_img):
1296
+ img, pts = clear_crop_points(base_img)
1297
+ return img, pts, "*Points cleared β€” click to set new vertices*"
1298
+
1299
+ clear_crop_btn.click(fn=on_clear_crop,
1300
+ inputs=[base_image_state],
1301
+ outputs=[crop_display, crop_points_state, crop_status])
1302
+
1303
+ segment_btn.click(
1304
+ fn=run_segmentation,
1305
+ inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1306
+ use_min_filter, use_max_filter,
1307
+ use_stereo, left_excl, top_excl, crop_points_state],
1308
+ outputs=[cell_count_out, overlay_out, info_out, viability_section,
1309
+ masks_state, image_state, confluency_out, min_size_recommendation, raw_image_state]
1310
+ )
1311
+
1312
+ # ---- Run Viability button -------------------------------------------
1313
+ def on_run_viability(stored_masks, stored_image):
1314
+ overlay, alive, dead, viab_pct, info, label_map = run_viability(stored_masks, stored_image)
1315
+ return overlay, alive, dead, viab_pct, info, label_map
1316
+
1317
+ viab_run_btn.click(
1318
+ fn=on_run_viability,
1319
+ inputs=[masks_state, image_state],
1320
+ outputs=[viab_overlay, live_count_out, dead_count_out,
1321
+ viab_percent_out, viab_info, label_map_state]
1322
+ ).then(
1323
+ fn=save_tab_result,
1324
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
1325
+ outputs=[result_state]
1326
+ )
1327
+
1328
+ # ---- Build correction grid -----------------------------------------
1329
+ def on_build_grid(stored_masks, stored_image, label_map, stored_raw_image):
1330
+ if stored_masks is None or stored_image is None or not label_map:
1331
+ return (gr.update(visible=False), [],
1332
+ gr.update(value="*Run viability analysis first.*", visible=True))
1333
+ masks = unpack_array(stored_masks)
1334
+ image_np = unpack_array(stored_image)
1335
+ raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1336
+ features = extract_cell_features(image_np, masks)
1337
+ labelled = attach_viability_labels(features, masks, image_np, label_map)
1338
+ if not labelled:
1339
+ return (gr.update(visible=False), [],
1340
+ gr.update(value="*No cells found.*", visible=True))
1341
+ grid = build_correction_grid(image_np, masks, labelled, raw_image_np)
1342
+ n = len(labelled)
1343
+ dead = sum(1 for r in labelled if r["label"] == 1)
1344
+ msg = (f"*{n} cells β€” {n-dead} live (green), {dead} dead (red). "
1345
+ f"Tap any thumbnail to flip its label.*")
1346
+ return gr.update(value=grid, visible=True), labelled, gr.update(value=msg, visible=True)
1347
+
1348
+ build_grid_btn.click(
1349
+ fn=on_build_grid,
1350
+ inputs=[masks_state, image_state, label_map_state, raw_image_state],
1351
+ outputs=[correction_grid, labelled_state, correction_status]
1352
+ )
1353
+
1354
+ # ---- Grid tap β€” flip label, update overlay + counts ----------------
1355
+ def on_grid_tap(labelled, stored_masks, stored_image, stored_raw_image, evt: gr.SelectData):
1356
+ if not labelled or stored_masks is None:
1357
+ return None, labelled, "", 0, 0, 0.0, None, {}
1358
+ masks = unpack_array(stored_masks)
1359
+ image_np = unpack_array(stored_image)
1360
+ raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1361
+ grid, updated, msg = toggle_cell_label(labelled, image_np, masks, raw_image_np, evt)
1362
+
1363
+ # Rebuild label_map from corrected labelled list
1364
+ new_label_map = {int(f["cell_id"]): int(f["label"]) for f in updated}
1365
+ overlay_np = draw_viability_overlay(image_np, masks, new_label_map)
1366
+ dead = sum(1 for f in updated if f["label"] == 1)
1367
+ alive = len(updated) - dead
1368
+ total = alive + dead
1369
+ viab_pct = (alive / total * 100) if total > 0 else 0.0
1370
+
1371
+ return (grid, updated, f"*{msg}*",
1372
+ alive, dead, viab_pct,
1373
+ Image.fromarray(overlay_np), new_label_map)
1374
+
1375
+ correction_grid.select(
1376
+ fn=on_grid_tap,
1377
+ inputs=[labelled_state, masks_state, image_state, raw_image_state],
1378
+ outputs=[correction_grid, labelled_state, correction_status,
1379
+ live_count_out, dead_count_out, viab_percent_out,
1380
+ viab_overlay, label_map_state]
1381
+ )
1382
+
1383
+ # ---- Export --------------------------------------------------------
1384
+ def on_export(stored_masks, stored_image, labelled, label_map):
1385
+ path, msg = prepare_export_corrected(stored_masks, stored_image, labelled, label_map)
1386
+ if path is None:
1387
+ return gr.update(visible=False), msg
1388
+ return gr.update(value=path, visible=True), msg
1389
+
1390
+ export_btn.click(
1391
+ fn=on_export,
1392
+ inputs=[masks_state, image_state, labelled_state, label_map_state],
1393
+ outputs=[export_file, export_info]
1394
+ )
1395
+
1396
+
1397
+
1398
+ # Gradio interface
1399
+
1400
+ with gr.Blocks(
1401
+ title="CellposeCellCounter",
1402
+ theme=gr.themes.Soft(),
1403
+ ) as demo:
1404
+ gr.Markdown("# CellposeCellCounter")
1405
+ 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.")
1406
+
1407
+ # Shared mask/image state (one pair per tab so tabs don't clobber each other)
1408
+ masks_states = [gr.State(value=None) for _ in range(4)]
1409
+ image_states = [gr.State(value=None) for _ in range(4)]
1410
+ result_states = [gr.State(value=None) for _ in range(4)]
1411
+
1412
+ # Build Tabs 1–4 with a loop
1413
+ for i in range(4):
1414
+ build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1415
+
1416
+ # -------------------------------------------------------------------------
1417
+ # Tab 5 β€” Summary
1418
+ # -------------------------------------------------------------------------
1419
+ with gr.Tab("Tab 5 β€” Summary"):
1420
+ gr.Markdown("## Average Results Across All Tabs")
1421
+ gr.Markdown(
1422
+ "Run segmentation in one or more tabs, "
1423
+ "then click **Refresh Summary** to see the averages."
1424
+ )
1425
+
1426
+ refresh_btn = gr.Button("πŸ”„ Refresh Summary", variant="primary", size="lg")
1427
+
1428
+ with gr.Row():
1429
+ avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
1430
+ avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
1431
+ avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
1432
+
1433
+ summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
1434
+
1435
+ refresh_btn.click(
1436
+ fn=compute_summary,
1437
+ inputs=result_states, # list of 4 gr.State components
1438
+ outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1439
+ )
1440
+
1441
+
1442
+
1443
+ if __name__ == "__main__":
1444
+ demo.launch()