Spaces:
Sleeping
Sleeping
Create cell_segmenter.py
Browse files- 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
|