Firoj112 commited on
Commit
e8fa364
Β·
verified Β·
1 Parent(s): 4ab6f4d

Create cell_segmenter.py

Browse files
Files changed (1) hide show
  1. core/cell_segmenter.py +244 -0
core/cell_segmenter.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HemaVision Cell Segmenter
3
+ ━━━━━━━━━━━━━━━━━━━━━━━━━
4
+ Detects and crops individual cells from whole blood smear field images.
5
+
6
+ When users upload full-field microscopy images (many cells visible),
7
+ this module segments them into individual cell crops so the model
8
+ can analyze each one independently.
9
+
10
+ Pipeline:
11
+ 1. Resize large images to a workable resolution
12
+ 2. Convert to grayscale β†’ Otsu threshold β†’ binary mask
13
+ 3. Morphological cleanup (open/close to remove noise)
14
+ 4. Find contours β†’ filter by area β†’ extract bounding boxes
15
+ 5. Expand bounding boxes to square cells with padding
16
+ 6. Crop each cell from the original full-resolution image
17
+
18
+ Author: Firoj
19
+ """
20
+
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from typing import List, Optional, Tuple
24
+
25
+ import numpy as np
26
+ from PIL import Image
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ try:
31
+ import cv2
32
+ _HAS_CV2 = True
33
+ except ImportError:
34
+ _HAS_CV2 = False
35
+ logger.warning("OpenCV not available β€” cell segmentation disabled")
36
+
37
+
38
+ # ── Configuration ────────────────────────────────────────────
39
+
40
+ # Minimum / maximum relative area of a detected cell region.
41
+ # Expressed as a fraction of the total image area.
42
+ MIN_CELL_AREA_FRAC = 0.002 # Cell must be > 0.2% of image
43
+ MAX_CELL_AREA_FRAC = 0.25 # Cell must be < 25% of image
44
+ MAX_CELLS = 30 # Don't return more than this
45
+ CELL_CROP_PAD = 0.15 # 15% padding around each cell crop
46
+ MIN_CELL_SIZE_PX = 32 # Minimum crop dimension in pixels
47
+
48
+ # Images below this size are already single-cell crops
49
+ SINGLE_CELL_THRESHOLD = 600 # w or h ≀ 600 β†’ single cell
50
+
51
+
52
+ @dataclass
53
+ class CellCrop:
54
+ """A single detected cell from a larger image."""
55
+ image: Image.Image # Cropped cell as PIL Image
56
+ bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2) in original coords
57
+ area: float # Contour area in pixels
58
+ center: Tuple[int, int] # Center (cx, cy) in original coords
59
+ index: int # Cell number (0-based)
60
+
61
+
62
+ @dataclass
63
+ class SegmentationResult:
64
+ """Result of cell segmentation."""
65
+ cells: List[CellCrop] = field(default_factory=list)
66
+ is_multi_cell: bool = False
67
+ original_size: Tuple[int, int] = (0, 0) # (w, h)
68
+ annotated_image: Optional[Image.Image] = None # Original with bboxes drawn
69
+ message: str = ""
70
+
71
+
72
+ def is_multi_cell_image(image: Image.Image) -> bool:
73
+ """Quick check: is this image likely a multi-cell field?"""
74
+ w, h = image.size
75
+ if w <= SINGLE_CELL_THRESHOLD and h <= SINGLE_CELL_THRESHOLD:
76
+ return False
77
+ if not _HAS_CV2:
78
+ return w > 1000 or h > 1000 # Fallback heuristic
79
+ # Do a quick contour check
80
+ result = segment_cells(image, max_cells=5, annotate=False)
81
+ return len(result.cells) > 1
82
+
83
+
84
+ def segment_cells(
85
+ image: Image.Image,
86
+ max_cells: int = MAX_CELLS,
87
+ annotate: bool = True,
88
+ ) -> SegmentationResult:
89
+ """
90
+ Segment individual cells from a microscopy image.
91
+
92
+ Args:
93
+ image: PIL Image (RGB)
94
+ max_cells: Maximum number of cells to return
95
+ annotate: Whether to draw bounding boxes on the original
96
+
97
+ Returns:
98
+ SegmentationResult with list of CellCrop objects
99
+ """
100
+ image_rgb = image.convert("RGB")
101
+ w, h = image_rgb.size
102
+ result = SegmentationResult(original_size=(w, h))
103
+
104
+ # If the image is small, treat it as a single cell
105
+ if w <= SINGLE_CELL_THRESHOLD and h <= SINGLE_CELL_THRESHOLD:
106
+ result.cells = [CellCrop(
107
+ image=image_rgb,
108
+ bbox=(0, 0, w, h),
109
+ area=float(w * h),
110
+ center=(w // 2, h // 2),
111
+ index=0,
112
+ )]
113
+ result.is_multi_cell = False
114
+ result.message = "Single-cell image detected β€” analyzing directly."
115
+ return result
116
+
117
+ if not _HAS_CV2:
118
+ # Fallback: return the whole image as one crop
119
+ result.cells = [CellCrop(
120
+ image=image_rgb,
121
+ bbox=(0, 0, w, h),
122
+ area=float(w * h),
123
+ center=(w // 2, h // 2),
124
+ index=0,
125
+ )]
126
+ result.message = "OpenCV not available β€” analyzing whole image as single cell."
127
+ return result
128
+
129
+ # ── Convert and threshold ────────────────────────────────
130
+ img_np = np.array(image_rgb)
131
+ # Work at reduced resolution for speed, keep original for cropping
132
+ scale = min(1.0, 1024.0 / max(w, h))
133
+ if scale < 1.0:
134
+ small = cv2.resize(img_np, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
135
+ else:
136
+ small = img_np.copy()
137
+
138
+ gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
139
+
140
+ # Adaptive threshold works better than Otsu for stained smears
141
+ # that have uneven illumination
142
+ blur = cv2.GaussianBlur(gray, (11, 11), 0)
143
+ _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
144
+
145
+ # ── Morphological cleanup ────────────────────────────────
146
+ kernel_size = max(3, int(7 * scale))
147
+ if kernel_size % 2 == 0:
148
+ kernel_size += 1
149
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
150
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2)
151
+ binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)
152
+
153
+ # ── Find contours ────────────────────────────────────────
154
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
155
+
156
+ sh, sw = small.shape[:2]
157
+ total_area = sh * sw
158
+ min_area = total_area * MIN_CELL_AREA_FRAC
159
+ max_area = total_area * MAX_CELL_AREA_FRAC
160
+
161
+ # Filter and sort by area (largest first)
162
+ valid_contours = []
163
+ for c in contours:
164
+ area = cv2.contourArea(c)
165
+ if min_area < area < max_area:
166
+ valid_contours.append((c, area))
167
+ valid_contours.sort(key=lambda x: x[1], reverse=True)
168
+ valid_contours = valid_contours[:max_cells]
169
+
170
+ if len(valid_contours) == 0:
171
+ # No cells found β€” return whole image
172
+ result.cells = [CellCrop(
173
+ image=image_rgb,
174
+ bbox=(0, 0, w, h),
175
+ area=float(w * h),
176
+ center=(w // 2, h // 2),
177
+ index=0,
178
+ )]
179
+ result.message = "No individual cells detected β€” analyzing whole image."
180
+ return result
181
+
182
+ # ── Extract cell crops ───────────────────────────────────
183
+ annotated = img_np.copy() if annotate else None
184
+ cells: List[CellCrop] = []
185
+
186
+ for idx, (contour, area) in enumerate(valid_contours):
187
+ x, y, cw, ch = cv2.boundingRect(contour)
188
+
189
+ # Scale bounding box back to original resolution
190
+ inv_scale = 1.0 / scale
191
+ ox = int(x * inv_scale)
192
+ oy = int(y * inv_scale)
193
+ ocw = int(cw * inv_scale)
194
+ och = int(ch * inv_scale)
195
+
196
+ # Make square with padding
197
+ side = max(ocw, och)
198
+ pad = int(side * CELL_CROP_PAD)
199
+ side += 2 * pad
200
+
201
+ cx = ox + ocw // 2
202
+ cy = oy + och // 2
203
+ x1 = max(0, cx - side // 2)
204
+ y1 = max(0, cy - side // 2)
205
+ x2 = min(w, x1 + side)
206
+ y2 = min(h, y1 + side)
207
+
208
+ # Ensure minimum size
209
+ if (x2 - x1) < MIN_CELL_SIZE_PX or (y2 - y1) < MIN_CELL_SIZE_PX:
210
+ continue
211
+
212
+ crop = image_rgb.crop((x1, y1, x2, y2))
213
+ cells.append(CellCrop(
214
+ image=crop,
215
+ bbox=(x1, y1, x2, y2),
216
+ area=float(area * inv_scale * inv_scale),
217
+ center=(cx, cy),
218
+ index=idx,
219
+ ))
220
+
221
+ # Draw on annotated image
222
+ if annotated is not None:
223
+ color = (59, 130, 246) # Blue
224
+ cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 3)
225
+ label = f"#{idx + 1}"
226
+ font_scale = max(0.6, min(1.5, side / 300))
227
+ thickness = max(1, int(font_scale * 2))
228
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
229
+ cv2.rectangle(annotated, (x1, y1 - th - 10), (x1 + tw + 10, y1), color, -1)
230
+ cv2.putText(annotated, label, (x1 + 5, y1 - 5),
231
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), thickness)
232
+
233
+ result.cells = cells
234
+ result.is_multi_cell = len(cells) > 1
235
+ result.annotated_image = Image.fromarray(annotated) if annotated is not None else None
236
+
237
+ n = len(cells)
238
+ if result.is_multi_cell:
239
+ result.message = f"Detected {n} cells in the blood smear β€” analyzing each individually."
240
+ else:
241
+ result.message = f"Single cell detected β€” analyzing directly."
242
+
243
+ logger.info(f"Segmented {n} cells from {w}Γ—{h} image")
244
+ return result