563 lines
15 KiB
Markdown
563 lines
15 KiB
Markdown
---
|
|
title: "Cell Counting Protocols"
|
|
task: ""
|
|
lineage_type: import
|
|
upstream_source: https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-image-analysis/references/cell_counting.md
|
|
upstream_sha: e2520a96
|
|
imported_at: 2026-06-26
|
|
prompt_class: prompt
|
|
upstream_changes: accepted
|
|
author: upstream
|
|
validated: false
|
|
---
|
|
|
|
# Cell Counting Protocols
|
|
|
|
Complete guide for counting cells and nuclei in fluorescence and brightfield microscopy images.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [DAPI/Nuclear Staining](#dapinuclear-staining)
|
|
3. [Watershed Segmentation](#watershed-segmentation)
|
|
4. [Fluorescence Marker Counting](#fluorescence-marker-counting)
|
|
5. [Brightfield Cell Counting](#brightfield-cell-counting)
|
|
6. [High-Density Counting](#high-density-counting)
|
|
7. [Quality Control](#quality-control)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
### When to Use Each Method
|
|
|
|
| Cell Type | Staining | Density | Best Method |
|
|
|-----------|----------|---------|-------------|
|
|
| Nuclei | DAPI/Hoechst | Low-Medium | Otsu + watershed |
|
|
| Nuclei | DAPI/Hoechst | High | Watershed with distance transform |
|
|
| Neurons | NeuN, MAP2 | Any | Threshold + watershed |
|
|
| Immune cells | CD markers | Low-Medium | Adaptive threshold |
|
|
| Cells (brightfield) | Phase contrast | Low | Adaptive threshold |
|
|
| Cells (brightfield) | Phase contrast | High | Edge detection + watershed |
|
|
| Touching/clustered | Any | High | CellPose or StarDist (external) |
|
|
|
|
---
|
|
|
|
## DAPI/Nuclear Staining
|
|
|
|
### Basic Nuclear Counting
|
|
|
|
```python
|
|
from skimage import filters, measure, morphology
|
|
from skimage.color import rgb2gray
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
def count_nuclei_basic(image, channel=None, threshold_method='otsu', min_area=50):
|
|
"""Count nuclei in DAPI/Hoechst-stained images.
|
|
|
|
Args:
|
|
image: numpy array (H, W) or (H, W, C)
|
|
channel: int, channel index for multi-channel images (e.g., 0 for DAPI)
|
|
threshold_method: 'otsu', 'li', 'triangle', or numeric value
|
|
min_area: minimum nucleus area in pixels (default 50)
|
|
|
|
Returns: (count, labeled_image, properties_df)
|
|
"""
|
|
# Extract channel if needed
|
|
if channel is not None and image.ndim >= 3:
|
|
img = image[..., channel].astype(float)
|
|
elif image.ndim == 3:
|
|
img = rgb2gray(image)
|
|
else:
|
|
img = image.astype(float)
|
|
|
|
# Threshold
|
|
if threshold_method == 'otsu':
|
|
thresh = filters.threshold_otsu(img)
|
|
elif threshold_method == 'li':
|
|
thresh = filters.threshold_li(img)
|
|
elif threshold_method == 'triangle':
|
|
thresh = filters.threshold_triangle(img)
|
|
else:
|
|
thresh = threshold_method # Use numeric value directly
|
|
|
|
binary = img > thresh
|
|
|
|
# Clean up small objects
|
|
binary = morphology.remove_small_objects(binary, min_size=min_area)
|
|
|
|
# Label connected components
|
|
labels = measure.label(binary)
|
|
|
|
# Measure properties
|
|
props = measure.regionprops_table(labels, img, properties=[
|
|
'label', 'area', 'mean_intensity', 'perimeter',
|
|
'major_axis_length', 'minor_axis_length', 'eccentricity'
|
|
])
|
|
props_df = pd.DataFrame(props)
|
|
|
|
count = labels.max()
|
|
|
|
return count, labels, props_df
|
|
|
|
|
|
# Example usage
|
|
import tifffile
|
|
image = tifffile.imread("dapi_image.tif")
|
|
count, labels, props = count_nuclei_basic(image, channel=0, min_area=50)
|
|
print(f"Found {count} nuclei")
|
|
print(props.head())
|
|
```
|
|
|
|
---
|
|
|
|
## Watershed Segmentation
|
|
|
|
### For Touching/Clustered Nuclei
|
|
|
|
```python
|
|
from scipy import ndimage
|
|
from skimage.feature import peak_local_max
|
|
from skimage.segmentation import watershed
|
|
|
|
def count_nuclei_watershed(image, channel=None, min_area=50, min_distance=10):
|
|
"""Count nuclei using watershed segmentation (for touching nuclei).
|
|
|
|
Args:
|
|
image: numpy array
|
|
channel: int, channel index for multi-channel
|
|
min_area: minimum nucleus area in pixels
|
|
min_distance: minimum distance between nuclei centers (pixels)
|
|
|
|
Returns: (count, labeled_image, properties_df)
|
|
"""
|
|
# Extract channel
|
|
if channel is not None and image.ndim >= 3:
|
|
img = image[..., channel].astype(float)
|
|
elif image.ndim == 3:
|
|
img = rgb2gray(image)
|
|
else:
|
|
img = image.astype(float)
|
|
|
|
# Threshold
|
|
thresh = filters.threshold_otsu(img)
|
|
binary = img > thresh
|
|
binary = morphology.remove_small_objects(binary, min_size=min_area)
|
|
|
|
# Distance transform
|
|
distance = ndimage.distance_transform_edt(binary)
|
|
|
|
# Find local maxima (nucleus centers)
|
|
coords = peak_local_max(distance, min_distance=min_distance, labels=binary)
|
|
mask = np.zeros(distance.shape, dtype=bool)
|
|
mask[tuple(coords.T)] = True
|
|
|
|
# Create markers
|
|
markers = measure.label(mask)
|
|
|
|
# Watershed
|
|
labels = watershed(-distance, markers, mask=binary)
|
|
|
|
# Measure properties
|
|
props = measure.regionprops_table(labels, img, properties=[
|
|
'label', 'area', 'mean_intensity', 'centroid',
|
|
'major_axis_length', 'minor_axis_length'
|
|
])
|
|
props_df = pd.DataFrame(props)
|
|
|
|
count = labels.max()
|
|
|
|
return count, labels, props_df
|
|
|
|
|
|
# Example usage
|
|
count, labels, props = count_nuclei_watershed(
|
|
image,
|
|
channel=0,
|
|
min_area=50,
|
|
min_distance=10
|
|
)
|
|
print(f"Found {count} nuclei (watershed)")
|
|
```
|
|
|
|
---
|
|
|
|
## Fluorescence Marker Counting
|
|
|
|
### NeuN, MAP2, or Other Neuronal Markers
|
|
|
|
```python
|
|
def count_marker_positive_cells(image, channel, threshold_percentile=75, min_area=30):
|
|
"""Count marker-positive cells (e.g., NeuN, MAP2).
|
|
|
|
Uses percentile-based thresholding for better adaptability.
|
|
|
|
Args:
|
|
image: numpy array
|
|
channel: int, channel index for marker
|
|
threshold_percentile: percentile for threshold (default 75)
|
|
min_area: minimum cell area
|
|
|
|
Returns: (count, labeled_image, properties_df)
|
|
"""
|
|
# Extract channel
|
|
img = image[..., channel].astype(float)
|
|
|
|
# Percentile-based threshold (more robust than Otsu for sparse markers)
|
|
thresh = np.percentile(img[img > 0], threshold_percentile)
|
|
binary = img > thresh
|
|
|
|
# Morphological cleanup
|
|
binary = morphology.remove_small_objects(binary, min_size=min_area)
|
|
binary = morphology.binary_opening(binary, morphology.disk(2))
|
|
binary = morphology.binary_closing(binary, morphology.disk(3))
|
|
|
|
# Label
|
|
labels = measure.label(binary)
|
|
|
|
# Measure
|
|
props = measure.regionprops_table(labels, img, properties=[
|
|
'label', 'area', 'mean_intensity', 'max_intensity',
|
|
'centroid', 'bbox'
|
|
])
|
|
props_df = pd.DataFrame(props)
|
|
|
|
count = labels.max()
|
|
|
|
return count, labels, props_df
|
|
|
|
|
|
# Example usage
|
|
neun_count, neun_labels, neun_props = count_marker_positive_cells(
|
|
image,
|
|
channel=1, # NeuN channel
|
|
threshold_percentile=75,
|
|
min_area=30
|
|
)
|
|
print(f"NeuN+ cells: {neun_count}")
|
|
```
|
|
|
|
---
|
|
|
|
## Brightfield Cell Counting
|
|
|
|
### Phase Contrast or Brightfield Images
|
|
|
|
```python
|
|
def count_cells_brightfield(image, adaptive_block_size=51, min_area=100):
|
|
"""Count cells in brightfield/phase contrast images.
|
|
|
|
Uses adaptive thresholding to handle uneven illumination.
|
|
|
|
Args:
|
|
image: numpy array (grayscale or RGB)
|
|
adaptive_block_size: block size for adaptive threshold (odd number)
|
|
min_area: minimum cell area
|
|
|
|
Returns: (count, labeled_image, properties_df)
|
|
"""
|
|
# Convert to grayscale
|
|
if image.ndim == 3:
|
|
img = rgb2gray(image)
|
|
else:
|
|
img = image.astype(float)
|
|
|
|
# Adaptive threshold
|
|
local_thresh = filters.threshold_local(img, block_size=adaptive_block_size, offset=0.01)
|
|
binary = img < local_thresh # Note: cells are usually darker than background
|
|
|
|
# Clean up
|
|
binary = morphology.remove_small_objects(binary, min_size=min_area)
|
|
binary = morphology.remove_small_holes(binary, area_threshold=min_area)
|
|
|
|
# Morphological opening to separate touching cells
|
|
binary = morphology.binary_opening(binary, morphology.disk(3))
|
|
|
|
# Label
|
|
labels = measure.label(binary)
|
|
|
|
# Measure
|
|
props = measure.regionprops_table(labels, img, properties=[
|
|
'label', 'area', 'perimeter', 'eccentricity',
|
|
'solidity', 'extent'
|
|
])
|
|
props_df = pd.DataFrame(props)
|
|
|
|
count = labels.max()
|
|
|
|
return count, labels, props_df
|
|
|
|
|
|
# Example usage
|
|
count, labels, props = count_cells_brightfield(
|
|
image,
|
|
adaptive_block_size=51,
|
|
min_area=100
|
|
)
|
|
print(f"Found {count} cells (brightfield)")
|
|
```
|
|
|
|
---
|
|
|
|
## High-Density Counting
|
|
|
|
### Advanced Watershed with Gaussian Filtering
|
|
|
|
```python
|
|
from skimage.filters import gaussian
|
|
|
|
def count_high_density_cells(image, channel=None, sigma=2.0, min_distance=5, min_area=20):
|
|
"""Count cells in high-density images.
|
|
|
|
Uses Gaussian smoothing before watershed for better separation.
|
|
|
|
Args:
|
|
image: numpy array
|
|
channel: channel index
|
|
sigma: Gaussian smoothing sigma (default 2.0)
|
|
min_distance: minimum distance between cell centers
|
|
min_area: minimum cell area
|
|
|
|
Returns: (count, labeled_image, properties_df)
|
|
"""
|
|
# Extract channel
|
|
if channel is not None and image.ndim >= 3:
|
|
img = image[..., channel].astype(float)
|
|
else:
|
|
img = image.astype(float)
|
|
|
|
# Smooth to reduce noise
|
|
img_smooth = gaussian(img, sigma=sigma)
|
|
|
|
# Threshold
|
|
thresh = filters.threshold_li(img_smooth) # Li works better for high density
|
|
binary = img_smooth > thresh
|
|
binary = morphology.remove_small_objects(binary, min_size=min_area)
|
|
|
|
# Distance transform
|
|
distance = ndimage.distance_transform_edt(binary)
|
|
distance_smooth = gaussian(distance, sigma=1.0) # Smooth distance map
|
|
|
|
# Find peaks
|
|
coords = peak_local_max(
|
|
distance_smooth,
|
|
min_distance=min_distance,
|
|
labels=binary,
|
|
exclude_border=False
|
|
)
|
|
mask = np.zeros(distance.shape, dtype=bool)
|
|
mask[tuple(coords.T)] = True
|
|
|
|
# Markers and watershed
|
|
markers = measure.label(mask)
|
|
labels = watershed(-distance_smooth, markers, mask=binary)
|
|
|
|
# Measure
|
|
props = measure.regionprops_table(labels, img, properties=[
|
|
'label', 'area', 'mean_intensity'
|
|
])
|
|
props_df = pd.DataFrame(props)
|
|
|
|
count = labels.max()
|
|
|
|
return count, labels, props_df
|
|
```
|
|
|
|
---
|
|
|
|
## Quality Control
|
|
|
|
### Filter by Size and Shape
|
|
|
|
```python
|
|
def filter_by_morphology(props_df, min_area=50, max_area=1000,
|
|
min_circularity=0.5, max_eccentricity=0.95):
|
|
"""Filter detected objects by morphological criteria.
|
|
|
|
Args:
|
|
props_df: DataFrame from regionprops_table
|
|
min_area: minimum area (pixels)
|
|
max_area: maximum area (pixels)
|
|
min_circularity: minimum circularity (0-1)
|
|
max_eccentricity: maximum eccentricity (0-1)
|
|
|
|
Returns: Filtered DataFrame
|
|
"""
|
|
# Calculate circularity if not present
|
|
if 'circularity' not in props_df.columns and 'perimeter' in props_df.columns:
|
|
props_df['circularity'] = 4 * np.pi * props_df['area'] / (props_df['perimeter']**2)
|
|
|
|
# Apply filters
|
|
filtered = props_df.copy()
|
|
|
|
# Size filters
|
|
filtered = filtered[filtered['area'] >= min_area]
|
|
filtered = filtered[filtered['area'] <= max_area]
|
|
|
|
# Shape filters
|
|
if 'circularity' in filtered.columns:
|
|
filtered = filtered[filtered['circularity'] >= min_circularity]
|
|
|
|
if 'eccentricity' in filtered.columns:
|
|
filtered = filtered[filtered['eccentricity'] <= max_eccentricity]
|
|
|
|
return filtered
|
|
|
|
|
|
# Example usage
|
|
count, labels, props = count_nuclei_watershed(image, channel=0)
|
|
props_filtered = filter_by_morphology(
|
|
props,
|
|
min_area=50,
|
|
max_area=500,
|
|
min_circularity=0.6
|
|
)
|
|
print(f"Before filtering: {len(props)} objects")
|
|
print(f"After filtering: {len(props_filtered)} nuclei")
|
|
```
|
|
|
|
### Visual Quality Check
|
|
|
|
```python
|
|
import matplotlib.pyplot as plt
|
|
from skimage.color import label2rgb
|
|
|
|
def visualize_segmentation(image, labels, title="Segmentation Result"):
|
|
"""Visualize segmentation overlay on original image.
|
|
|
|
Args:
|
|
image: Original image (2D grayscale)
|
|
labels: Labeled image from segmentation
|
|
title: Plot title
|
|
"""
|
|
# Create overlay
|
|
overlay = label2rgb(labels, image=image, bg_label=0, alpha=0.3)
|
|
|
|
# Plot
|
|
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
|
|
|
|
axes[0].imshow(image, cmap='gray')
|
|
axes[0].set_title("Original")
|
|
axes[0].axis('off')
|
|
|
|
axes[1].imshow(labels, cmap='nipy_spectral')
|
|
axes[1].set_title(f"Labels (n={labels.max()})")
|
|
axes[1].axis('off')
|
|
|
|
axes[2].imshow(overlay)
|
|
axes[2].set_title("Overlay")
|
|
axes[2].axis('off')
|
|
|
|
plt.suptitle(title)
|
|
plt.tight_layout()
|
|
plt.show()
|
|
|
|
|
|
# Example usage
|
|
count, labels, props = count_nuclei_watershed(image, channel=0)
|
|
visualize_segmentation(image[..., 0], labels, title=f"Nuclei: {count} cells")
|
|
```
|
|
|
|
---
|
|
|
|
## Multi-Sample Analysis
|
|
|
|
### Batch Counting Across Multiple Images
|
|
|
|
```python
|
|
def batch_count_cells(image_paths, count_function, **kwargs):
|
|
"""Count cells in multiple images.
|
|
|
|
Args:
|
|
image_paths: List of image file paths
|
|
count_function: Function to use for counting (e.g., count_nuclei_watershed)
|
|
**kwargs: Arguments to pass to count_function
|
|
|
|
Returns: DataFrame with results per image
|
|
"""
|
|
results = []
|
|
|
|
for img_path in image_paths:
|
|
# Load image
|
|
img = tifffile.imread(img_path)
|
|
|
|
# Count
|
|
count, labels, props = count_function(img, **kwargs)
|
|
|
|
# Store result
|
|
results.append({
|
|
'image': os.path.basename(img_path),
|
|
'count': count,
|
|
'mean_area': props['area'].mean(),
|
|
'std_area': props['area'].std(),
|
|
'mean_intensity': props['mean_intensity'].mean() if 'mean_intensity' in props else np.nan
|
|
})
|
|
|
|
return pd.DataFrame(results)
|
|
|
|
|
|
# Example usage
|
|
import glob
|
|
image_paths = glob.glob("images/*.tif")
|
|
results = batch_count_cells(
|
|
image_paths,
|
|
count_nuclei_watershed,
|
|
channel=0,
|
|
min_area=50,
|
|
min_distance=10
|
|
)
|
|
print(results)
|
|
results.to_csv("cell_counts.csv", index=False)
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
**Under-segmentation (cells merged)**:
|
|
- Decrease `min_distance` in watershed
|
|
- Use stronger Gaussian smoothing before watershed
|
|
- Try CellPose or StarDist for very dense images
|
|
|
|
**Over-segmentation (cells split)**:
|
|
- Increase `min_distance` in watershed
|
|
- Reduce Gaussian smoothing
|
|
- Use more aggressive morphological closing
|
|
|
|
**Background noise detected as cells**:
|
|
- Increase `min_area` threshold
|
|
- Use more conservative threshold method (Li instead of Otsu)
|
|
- Apply morphological opening
|
|
|
|
**Dim cells not detected**:
|
|
- Use Li or Triangle threshold instead of Otsu
|
|
- Reduce threshold manually (e.g., percentile-based)
|
|
- Enhance contrast before segmentation
|
|
|
|
**Uneven illumination**:
|
|
- Use adaptive thresholding (`filters.threshold_local()`)
|
|
- Apply background subtraction
|
|
- Use rolling ball background subtraction (from scikit-image)
|
|
|
|
---
|
|
|
|
## Parameter Selection Guide
|
|
|
|
### Starting Parameters by Image Type
|
|
|
|
| Image Type | Threshold | min_area | min_distance | Notes |
|
|
|------------|-----------|----------|--------------|-------|
|
|
| **DAPI (confocal)** | otsu | 50 | 10 | Well-separated nuclei |
|
|
| **DAPI (widefield)** | li | 50 | 8 | More variable intensity |
|
|
| **DAPI (high-density)** | li | 30 | 5 | Touching nuclei |
|
|
| **NeuN (sparse)** | percentile 75 | 40 | 15 | Sparse marker |
|
|
| **Phase contrast** | adaptive | 100 | 15 | Uneven illumination |
|
|
| **Brightfield** | adaptive | 150 | 20 | Large cells, halo artifacts |
|
|
|
|
Adjust based on your specific images!
|