Detecting Blood Cells in Medical Images: A Naive Approach
Identifying and counting different types of blood cells is a crucial task in many medical applications. Abnormal red or white blood cell counts can be indicators of serious conditions like anemia, infection, or blood cancers. Manually examining blood samples under a microscope is time-consuming and tedious. In recent years, there has been growing interest in developing automated methods to detect blood cells in microscopic images.
While advanced deep learning techniques have achieved impressive results, simpler "naive" approaches can still be useful as a starting point. In this article, we‘ll take an in-depth look at how to detect blood cells in images using a naive method. We‘ll go through the steps with code examples and discuss the strengths and limitations of this approach.
What is the Naive Approach?
The basic idea behind the naive approach is to break the image down into smaller patches, classify each patch separately, and then aggregate those classifications to find the blood cells. More specifically, it consists of these main steps:
- Split the input image into a grid of patches
- For each patch, determine if it contains the target blood cell or not (a binary classification problem)
- Keep the patches classified as positive and discard the rest
- The center of each remaining patch gives the predicted location of a blood cell in the image
This may seem almost too simple – and it does have some significant limitations we‘ll discuss later. But it can actually work decently well, especially when the blood cells are nicely separated and not too small compared to the patch size. The naive approach also has the advantage of being easy to understand and implement.
Step 1: Acquiring and Preprocessing the Dataset
First, we need a labeled dataset of blood cell images to train and test our detection model. Ideally this would consist of a large number of high-quality microscopic images from different samples, with bounding boxes drawn around each blood cell. In practice, datasets like this can be difficult and expensive to obtain due to the manual annotation required.
For this example, we‘ll use a dataset from a Kaggle competition that contains 212 images of white blood cells from blood smear slides. The dataset includes bounding box annotations in XML format. Here are a couple example images:
[Insert example WBC images from dataset]Before training our model, there are a few preprocessing steps we should take:
- Examine the images and labels to check for any major issues
- Normalize the pixel values to a consistent range like [0,1]
- Resize or crop the images to a standard size
- Convert the bounding box coordinates to a more convenient format
- Split the data into training, validation and test sets
Here‘s some example code to read in an image and normalize it:
import cv2
import numpy as np
# Read RGB image
img = cv2.imread(‘wbc_image.jpg‘)
# Convert to float and divide by 255
img = img.astype(np.float32) / 255.0
And here‘s how we might parse the XML annotation into a dict:
import xml.etree.ElementTree as ET
def parse_annotation(xml_path):
root = ET.parse(xml_path).getroot()
annotation = {}
annotation[‘image_path‘] = root.find(‘path‘).text
annotation[‘boxes‘] = []
for box in root.findall(‘object‘):
xmin = int(box.find(‘bndbox/xmin‘).text)
ymin = int(box.find(‘bndbox/ymin‘).text)
xmax = int(box.find(‘bndbox/xmax‘).text)
ymax = int(box.find(‘bndbox/ymax‘).text)
annotation[‘boxes‘].append((xmin, ymin, xmax, ymax))
return annotation
Step 2: Splitting Images into Patches
The next step is to divide each training image into a grid of smaller patches. The size of the patches is an important hyperparameter – patches that are too large will miss smaller blood cells, while patches that are too small require many more classifications and may not contain enough context. For our dataset, a patch size of 60×60 pixels seems to be a decent compromise.
We can use a sliding window to extract patches with a given stride (step size). Selecting an appropriate stride is a trade-off between patch overlap and computational cost. Here‘s a function to split an image into patches:
def extract_patches(img, patch_size, stride):
patches = []
for y in range(0, img.shape[0] - patch_size + 1, stride):
for x in range(0, img.shape[1] - patch_size + 1, stride):
patch = img[y:y+patch_size, x:x+patch_size]
patches.append(patch)
return np.array(patches)
We‘ll also need to determine if each patch is a positive (contains a blood cell) or negative example. One way to do this is to loop through the ground truth bounding boxes and check if the patch overlaps with any of them by a certain threshold. This threshold controls the tradeoff between missing some cells (false negatives) and labeling too many patches as positive (false positives). Here‘s an implementation:
def label_patches(patches, boxes, threshold=0.5):
labels = np.zeros(len(patches))
patch_size = patches.shape[1]
for i, patch in enumerate(patches):
patch_box = (patch[0], patch[1], patch[0]+patch_size, patch[1]+patch_size)
for box in boxes:
if iou(patch_box, box) > threshold:
labels[i] = 1
break
return labels
def iou(box1, box2):
inter_area = intersection_area(box1, box2)
union_area = area(box1) + area(box2) - inter_area
return inter_area / union_area
def intersection_area(box1, box2):
xmin = max(box1[0], box2[0])
ymin = max(box1[1], box2[1])
xmax = min(box1[2], box2[2])
ymax = min(box1[3], box2[3])
return max(0, xmax - xmin) * max(0, ymax - ymin)
def area(box):
return (box[2] - box[0]) * (box[3] - box[1])
The key step here is calculating the intersection over union (IOU) between each patch and ground truth box. This measures the overlap between them. Patches with an IOU greater than the threshold are considered positive examples.
Step 3: Training a Patch Classifier
At this point, we‘ve turned our blood cell detection problem into a binary classification problem. For each image patch, we need to predict if it‘s a positive example (contains a blood cell) or negative example (doesn‘t contain a blood cell). There are a variety of classifiers we could use, from simple linear models to deep convolutional neural networks.
For this naive approach, we‘ll opt for a relatively compact CNN with a few convolutional and pooling layers followed by fully connected layers. Here‘s an example architecture:
import tensorflow as tf
def my_model():
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(16, 3, activation=‘relu‘, input_shape=(60, 60, 3)),
tf.keras.layers.MaxPool2D(2),
tf.keras.layers.Conv2D(32, 3, activation=‘relu‘),
tf.keras.layers.MaxPool2D(2),
tf.keras.layers.Conv2D(64, 3, activation=‘relu‘),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(64, activation=‘relu‘),
tf.keras.layers.Dense(1, activation=‘sigmoid‘)
])
return model
We can train this model with standard techniques, using binary cross-entropy as the loss function:
model = my_model()
model.compile(optimizer=‘adam‘,
loss=‘binary_crossentropy‘,
metrics=[‘accuracy‘])
history = model.fit(train_patches, train_labels,
epochs=10,
validation_data=(val_patches, val_labels))
Step 4: Detecting Cells in New Images
To detect blood cells in a new test image, we can use our trained classifier to make predictions on patches from that image. The process looks like:
- Extract patches from the test image
- Apply the classifier to each patch
- Filter the patches to keep those with a positive prediction (probability > 0.5)
- Take the center of each remaining patch as the predicted blood cell location
We can wrap this logic up into a function:
def detect_cells(img, model, patch_size=60, stride=30, threshold=0.5):
patches = extract_patches(img, patch_size, stride)
predictions = model.predict(patches)
detections = []
for i, pred in enumerate(predictions):
if pred > threshold:
y = patches[i][1]
x = patches[i][0]
detections.append((x + patch_size//2, y + patch_size//2))
return detections
Evaluating the performance of this approach requires comparing the predicted cell locations to the ground truth bounding boxes. Metrics like precision, recall and F1 score can quantify how well the model is doing. We‘d also want to visualize the results by plotting the predicted cell locations on the original image.
Limitations of the Naive Approach
While a good starting point, the naive sliding window approach has some important drawbacks and limitations:
-
Lacks contextual information: Looking at small patches rather than the whole image means the model can‘t learn features based on the overall structure and appearance of the blood cells. For example, it might struggle to distinguish between white blood cells and other circular objects.
-
Computationally inefficient: Making a prediction for every patch in a full-sized image requires a huge number of patches and becomes very slow. This is tricky to scale to high-resolution images or real-time applications.
-
Patch edge effects: Cells that are split across multiple patches are harder to detect. The model may only see part of the cell in each patch.
-
Overlapping detections: The model can predict multiple positive patches for a single large cell, leading to duplicate detections that need to be consolidated.
-
Fixed aspect ratio: Fitting cells into square patches doesn‘t accommodate different cell shapes and orientations very well. For elongated or overlapping cells this can hurt accuracy.
-
Hyperparameter sensitive: The patch size and stride greatly impact performance and the optimal values vary for different datasets. Finding a patch size that works for cells of varying sizes is hard.
More advanced approaches aim to solve these problems in different ways. For example, they might use a two-stage pipeline with a region proposal step, multi-scale features, and anchor boxes. Newer single-stage detectors like YOLO and SSD learn to predict bounding boxes directly in a single forward pass. Methods based on pixel embeddings are also becoming popular for identifying cell instances without explicitly predicting boxes.
Conclusion
In this article, we walked through the key steps of detecting blood cells in images using a naive sliding window approach:
- Acquiring and preprocessing a dataset of labeled blood cell images
- Extracting fixed-size patches with a sliding window
- Classifying each patch as cell or no cell with a basic CNN
- Filtering the positive patches and taking their centers as cell detections
While limited in some significant ways, the naive approach provides an intuitive and easy to implement baseline for this task. It works best in simplified cases where the blood cells are well separated and similar in size. Understanding the core principles behind the naive method also helps clarify the goals and challenges that more sophisticated approaches aim to address.
With the right data and additional algorithm development, this general paradigm of patch classification has the potential to help automate blood cell detection and counting. This could ultimately lead to more efficient diagnoses of blood-based diseases and disorders. At the very least, the naive approach is a solid foundation to iterate and improve upon.