LiangLabUMB commited on
Commit
4deb2a3
·
verified ·
1 Parent(s): a78e921

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +441 -0
app.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
9
+ import io
10
+ from huggingface_hub import hf_hub_download
11
+ import base64 # needed for mobile ImageEditor base64 handling
12
+
13
+ HF_REPO_ID = "myang4218/cellposemodel"
14
+ MODEL_OPTIONS = {
15
+ "Hemocytometer Model": "hemocytometermodel.npy",
16
+ "General Model": "generalmodel.npy"
17
+ }
18
+
19
+ loaded_models = {}
20
+
21
+ # ---- mobile-safe size limits (aggressive for Safari) ----
22
+ MAX_SIDE = 384
23
+ MAX_PIXELS = 384 * 384
24
+
25
+
26
+ def safe_resize(image_np):
27
+ """
28
+ Downscale image to fit within MAX_SIDE and MAX_PIXELS while
29
+ preserving aspect ratio. Works for RGB / RGBA / grayscale.
30
+ """
31
+ h, w = image_np.shape[:2]
32
+ total = h * w
33
+
34
+ # already small enough
35
+ if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
36
+ return image_np
37
+
38
+ # compute scale so BOTH constraints are satisfied
39
+ scale_side = MAX_SIDE / max(h, w)
40
+ scale_pixels = (MAX_PIXELS / total) ** 0.5
41
+ scale = min(scale_side, scale_pixels)
42
+
43
+ new_w = max(1, int(w * scale))
44
+ new_h = max(1, int(h * scale))
45
+
46
+ return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
47
+
48
+
49
+ def extract_region_from_editor(editor_data):
50
+ """Extract selected region from ImageEditor data (mobile-safe)."""
51
+ if editor_data is None:
52
+ return None, None
53
+
54
+ # CASE 1 — Editor returns a PIL Image directly
55
+ if isinstance(editor_data, Image.Image):
56
+ return np.array(editor_data), None
57
+
58
+ # CASE 2 — Editor returns a NumPy array
59
+ if isinstance(editor_data, np.ndarray):
60
+ return editor_data, None
61
+
62
+ # CASE 3 — Mobile browser returns dict structure
63
+ if isinstance(editor_data, dict):
64
+
65
+ # --- Get background from multiple possible formats ---
66
+ bg = editor_data.get("background")
67
+
68
+ if bg is None:
69
+ return None, None
70
+
71
+ # Mobile ImageEditor sometimes wraps image like: {'image': base64, 'mask': None}
72
+ if isinstance(bg, dict) and "image" in bg:
73
+ try:
74
+ # Decode base64 → PIL → NumPy
75
+ img_bytes = io.BytesIO(base64.b64decode(bg["image"].split(",")[-1]))
76
+ bg_img = Image.open(img_bytes).convert("RGB")
77
+ background_np = np.array(bg_img)
78
+ except Exception:
79
+ return None, None
80
+
81
+ # Desktop / some mobile: bg is a PIL image
82
+ elif isinstance(bg, Image.Image):
83
+ background_np = np.array(bg)
84
+
85
+ # Final fallback
86
+ else:
87
+ try:
88
+ background_np = np.array(bg)
89
+ except Exception:
90
+ return None, None
91
+
92
+ # --- Layers (selection mask) ---
93
+ layers = editor_data.get("layers", [])
94
+ if layers:
95
+ sel = layers[0]
96
+
97
+ # Convert to NumPy if needed
98
+ if isinstance(sel, Image.Image):
99
+ sel_np = np.array(sel)
100
+ elif isinstance(sel, dict) and "image" in sel:
101
+ try:
102
+ sel_bytes = io.BytesIO(base64.b64decode(sel["image"].split(",")[-1]))
103
+ sel_np = np.array(Image.open(sel_bytes).convert("RGBA"))
104
+ except Exception:
105
+ sel_np = None
106
+ else:
107
+ try:
108
+ sel_np = np.array(sel)
109
+ except Exception:
110
+ sel_np = None
111
+
112
+ # If selection exists: compute bounding box
113
+ if sel_np is not None:
114
+ if sel_np.ndim == 3:
115
+ if sel_np.shape[2] == 4:
116
+ mask = sel_np[:, :, 3] > 0
117
+ else:
118
+ mask = np.any(sel_np > 0, axis=2)
119
+ else:
120
+ mask = sel_np > 0
121
+
122
+ coords = np.where(mask)
123
+ if coords[0].size > 0:
124
+ y0, y1 = coords[0].min(), coords[0].max()
125
+ x0, x1 = coords[1].min(), coords[1].max()
126
+ return background_np[y0:y1 + 1, x0:x1 + 1], (x0, y0, x1, y1)
127
+
128
+ # No selection → return full image
129
+ return background_np, None
130
+
131
+ # CASE 4 — Unknown structure
132
+ return None, None
133
+
134
+
135
+ def classify_cells_by_blueness(image_np, masks, blue_threshold):
136
+ """
137
+ Classify cells as dead (blue) or alive based on single blueness metric
138
+
139
+ Args:
140
+ image_np: RGB image array
141
+ masks: Cellpose segmentation masks
142
+ blue_threshold: Single threshold value (0-100) for blueness detection
143
+
144
+ Returns:
145
+ dead_count, alive_count, colored_overlay
146
+ """
147
+ # Ensure image_np is RGB for consistency with HSV conversion
148
+ if len(image_np.shape) == 2:
149
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
150
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
151
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
152
+
153
+ # Convert RGB to HSV
154
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
155
+
156
+ # Calculate blueness index for each pixel
157
+ hue = hsv[:, :, 0].astype(np.float32)
158
+ saturation = hsv[:, :, 1].astype(np.float32)
159
+
160
+ # Hue score: peaks around 115 (blue in HSV), drops off towards edges
161
+ # Handle hue wrap-around for blue detection (100-130 range)
162
+ hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
163
+ hue_score = np.maximum(0, 1 - hue_distance / 65) # 65 gives good blue range
164
+
165
+ # Combine hue proximity with saturation intensity
166
+ blueness = hue_score * (saturation / 255.0)
167
+
168
+ # Convert threshold from 0-100 to 0-1 scale
169
+ threshold = blue_threshold / 100.0
170
+
171
+ # Get unique cell IDs (excluding background)
172
+ cell_ids = np.unique(masks)
173
+ cell_ids = cell_ids[cell_ids > 0] # Remove background (0)
174
+
175
+ dead_cells = []
176
+ alive_cells = []
177
+
178
+ # Classify each cell
179
+ for cell_id in cell_ids:
180
+ # Get mask for this specific cell
181
+ cell_mask = (masks == cell_id)
182
+
183
+ # Calculate average blueness for this cell
184
+ cell_blueness = np.mean(blueness[cell_mask])
185
+
186
+ # Classify based on threshold
187
+ if cell_blueness > threshold:
188
+ dead_cells.append(cell_id)
189
+ else:
190
+ alive_cells.append(cell_id)
191
+
192
+ # Create colored overlay
193
+ overlay = image_np.copy().astype(np.float32) # Ensure float for blending
194
+
195
+ # Color dead cells red, alive cells green
196
+ for cell_id in dead_cells:
197
+ cell_mask = (masks == cell_id)
198
+ overlay[cell_mask] = [255, 0, 0] # Red for dead
199
+
200
+ for cell_id in alive_cells:
201
+ cell_mask = (masks == cell_id)
202
+ overlay[cell_mask] = [0, 255, 0] # Green for alive
203
+
204
+ # Blend with original image
205
+ alpha = 0.4
206
+ final_overlay = (1 - alpha) * image_np.astype(np.float32) + alpha * overlay
207
+ final_overlay = np.clip(final_overlay, 0, 255).astype(np.uint8)
208
+
209
+ return len(dead_cells), len(alive_cells), final_overlay
210
+
211
+
212
+ def measure_confluency(masks, image_np):
213
+ """Calculate the percentage of image area covered by cells"""
214
+ tot_pixels = image_np.shape[0] * image_np.shape[1]
215
+ cell_pixels = np.count_nonzero(masks)
216
+ confluency = cell_pixels / tot_pixels * 100
217
+ return confluency
218
+
219
+
220
+ @spaces.GPU
221
+ def run_segmentation_editor(editor_data, model_choice):
222
+ """
223
+ Runs cell segmentation using ImageEditor data.
224
+ Returns initial segmentation overlay, counts, confluency, and also masks/image for state.
225
+ """
226
+ try:
227
+ model_filename = MODEL_OPTIONS[model_choice]
228
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
229
+
230
+ if model_filename in loaded_models:
231
+ model = loaded_models[model_filename]
232
+ else:
233
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
234
+ loaded_models[model_filename] = model
235
+
236
+ region_np, region_coords = extract_region_from_editor(editor_data)
237
+
238
+ if region_np is None:
239
+ return 0, None, f"No image provided.", gr.update(visible=False), None, None, 0.0
240
+
241
+ # Enforce mobile-safe size limit immediately
242
+ region_np = safe_resize(region_np)
243
+
244
+ # Process image format to RGB
245
+ if len(region_np.shape) == 2:
246
+ processed_image_np = cv2.cvtColor(region_np, cv2.COLOR_GRAY2RGB)
247
+ elif len(region_np.shape) == 3 and region_np.shape[2] == 4:
248
+ processed_image_np = cv2.cvtColor(region_np, cv2.COLOR_RGBA2RGB)
249
+ else:
250
+ processed_image_np = region_np
251
+
252
+ # Run Cellpose segmentation
253
+ masks, flows, styles = model.eval(processed_image_np, diameter=None, channels=[0, 0])
254
+
255
+ cell_count = len(np.unique(masks)) - 1
256
+ confluency = measure_confluency(masks, processed_image_np)
257
+
258
+ # Create a basic segmentation overlay (without viability)
259
+ segmentation_overlay = processed_image_np.copy().astype(np.float32)
260
+ if masks.max() > 0:
261
+ np.random.seed(42) # For consistent random colors
262
+ colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
263
+ colors[0] = [0, 0, 0] # Background color
264
+ colored_mask = colors[masks]
265
+ alpha = 0.4
266
+ segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
267
+ segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
268
+
269
+ info_msg = f"Segmentation complete! Found {cell_count} cells.\n"
270
+ info_msg += f"Confluency: {confluency:.1f}%\n"
271
+ if region_coords:
272
+ info_msg += f"Processed region: {region_coords[0]},{region_coords[1]} to {region_coords[2]},{region_coords[3]}\n"
273
+ info_msg += f"Now adjust the Blue Threshold for viability assessment."
274
+
275
+ # Return initial segmentation display and state variables (packed for Gradio State)
276
+ return (
277
+ cell_count,
278
+ Image.fromarray(segmentation_overlay),
279
+ info_msg,
280
+ gr.update(visible=True),
281
+ pack_array(masks),
282
+ pack_array(processed_image_np),
283
+ confluency,
284
+ )
285
+
286
+ except Exception as e:
287
+ return 0, None, f"Error during segmentation: {str(e)}", gr.update(visible=False), None, None, 0.0
288
+
289
+
290
+
291
+ def update_viability_realtime(blue_threshold, stored_masks, stored_image_np):
292
+ # avoid unpacking None (e.g. slider moved before segmentation)
293
+ if stored_masks is None or stored_image_np is None:
294
+ return None, 0, 0, 0.0, "Please run segmentation first."
295
+
296
+ stored_masks = unpack_array(stored_masks)
297
+ stored_image_np = unpack_array(stored_image_np)
298
+
299
+ try:
300
+ dead_count, alive_count, viability_overlay_np = classify_cells_by_blueness(
301
+ stored_image_np, stored_masks, blue_threshold
302
+ )
303
+
304
+ total_count = alive_count + dead_count
305
+ viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
306
+ confluency = measure_confluency(stored_masks, stored_image_np)
307
+
308
+ overlay_image = Image.fromarray(viability_overlay_np)
309
+ info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
310
+ info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\nBlue threshold: {blue_threshold}%"
311
+
312
+ return overlay_image, alive_count, dead_count, viability_percent, info_msg
313
+
314
+ except Exception as e:
315
+ return None, 0, 0, 0.0, f"Error updating viability: {str(e)}"
316
+
317
+
318
+
319
+ def pack_array(arr):
320
+ pil = Image.fromarray(arr.astype(np.uint8))
321
+ buf = io.BytesIO()
322
+ pil.save(buf, format="PNG")
323
+ return buf.getvalue()
324
+
325
+
326
+ def unpack_array(data):
327
+ return np.array(Image.open(io.BytesIO(data)))
328
+
329
+
330
+ # Gradio interface
331
+ with gr.Blocks(
332
+ title="CellposeCellCounter",
333
+ theme=gr.themes.Soft(),
334
+ ) as demo:
335
+ gr.Markdown("# CellposeCellCounter")
336
+ gr.Markdown("For accurate cell confluency, crop the image to display only desired area.")
337
+
338
+ # Define State components to store masks and image data across function calls
339
+ masks_state = gr.State(value=None)
340
+ image_state = gr.State(value=None)
341
+
342
+ with gr.Tab("Image Editor (Draw Selection)"):
343
+ gr.Markdown("### Draw selection and run segmentation")
344
+
345
+ with gr.Row():
346
+ with gr.Column():
347
+ image_editor = gr.ImageEditor(
348
+ label="Draw selection on image",
349
+ type="pil",
350
+ image_mode="RGB",
351
+ height=512, # limit canvas size on client (important for mobile)
352
+ brush=gr.Brush(colors=["#ff0000"], color_mode="fixed", default_size=20),
353
+ eraser=gr.Eraser(default_size=20)
354
+ )
355
+ model_dropdown1 = gr.Dropdown(
356
+ choices=list(MODEL_OPTIONS.keys()),
357
+ label="Select Model",
358
+ value="Hemocytometer Model"
359
+ )
360
+ segment_btn1 = gr.Button("🔬 Run Segmentation", variant="primary", size="lg")
361
+
362
+ with gr.Column():
363
+ cell_count_output1 = gr.Number(label="Total Cells Detected", precision=0)
364
+ confluency_output1 = gr.Number(label="Confluency (%)", precision=1)
365
+ overlay_output1 = gr.Image(type="pil", label="Segmentation Result")
366
+ info_output1 = gr.Textbox(label="Processing Info", lines=4)
367
+
368
+ # Viability Assessment Section
369
+ with gr.Group(visible=False) as viability_section1:
370
+ gr.Markdown("### Viability Assessment (Trypan Blue)")
371
+ gr.Markdown("Adjust the threshold to classify cells as live (green) or dead (red).")
372
+
373
+ with gr.Row():
374
+ with gr.Column():
375
+ blue_threshold1 = gr.Slider(
376
+ minimum=0,
377
+ maximum=100,
378
+ value=25,
379
+ step=1,
380
+ label="Blue Threshold (%)",
381
+ info="Higher values = more selective for blue cells"
382
+ )
383
+
384
+ with gr.Column():
385
+ live_count_output1 = gr.Number(label="Live Cells (Green)", precision=0)
386
+ dead_count_output1 = gr.Number(label="Dead Cells (Red)", precision=0)
387
+
388
+ viability_overlay1 = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
389
+ viability_percent_output1 = gr.Number(label="Viability (%)", precision=1)
390
+ viability_info1 = gr.Textbox(label="Analysis Results", lines=5)
391
+
392
+ # Event handlers
393
+ segment_btn1.click(
394
+ fn=run_segmentation_editor,
395
+ inputs=[image_editor, model_dropdown1],
396
+ outputs=[cell_count_output1, overlay_output1, info_output1, viability_section1, masks_state, image_state, confluency_output1]
397
+ ).then( # Chain the initial viability assessment after segmentation
398
+ fn=update_viability_realtime,
399
+ inputs=[blue_threshold1, masks_state, image_state], # Pass stored state as inputs
400
+ outputs=[viability_overlay1, live_count_output1, dead_count_output1, viability_percent_output1, viability_info1]
401
+ )
402
+
403
+ # Slider changes update viability in real-time
404
+ blue_threshold1.change(
405
+ fn=update_viability_realtime,
406
+ inputs=[blue_threshold1, masks_state, image_state],
407
+ outputs=[viability_overlay1, live_count_output1, dead_count_output1, viability_percent_output1, viability_info1]
408
+ )
409
+
410
+
411
+
412
+ # Instructions
413
+ with gr.Accordion("Instructions", open=False):
414
+ gr.Markdown("""
415
+ ### How to use:
416
+
417
+ 1. **Upload and Segment**:
418
+ - Upload your microscopy image.
419
+ - Select a Cellpose model (e.g., "Hemocytometer Model" for suspension culture).
420
+ - Draw a selection region using the Image Editor.
421
+ - Click "Run Segmentation".
422
+
423
+ 2. **Analysis Results**:
424
+ - **Cell Count**: Total number of detected cells
425
+ - **Confluency**: Percentage of image area covered by cells (useful for assessing cell density). Note that cell confluency is calculated per the entire area of the image input.
426
+
427
+ 3. **Real-time Viability Assessment (Trypan Blue)**:
428
+ - After segmentation, the viability section will become visible.
429
+ - This tool is specifically designed for **Trypan Blue stained images**, where dead cells appear blue.
430
+ - Adjust the **"Blue Threshold (%)"** slider in real-time. As you change it, the green (live) and red (dead) classification on the overlay will update.
431
+ - **Lower values (e.g., 10-20%)** are more sensitive and will classify more cells as blue/dead.
432
+ - **Higher values (e.g., 30-50%)** are more selective and will only classify strongly blue cells as dead.
433
+ - Green cells = Live, Red cells = Dead.
434
+
435
+ 4. **Interpreting Results**:
436
+ - The app calculates and displays the total, live, and dead cell counts, along with the viability percentage and confluency.
437
+ - **Confluency** helps assess how densely packed your cells are, which is important for cell culture monitoring.
438
+ """)
439
+
440
+ if __name__ == "__main__":
441
+ demo.launch()