Segmentación de células cancerosas
En este post aplicamos segmentación semántica para localizar células cancerosas de cáncer de mama triple negativo en una imagen.
- ¿Qué es la segmentación de imágenes?
- Cáncer de mama triple negativo
- Descripción del dataset
- Carga de datos y preprocesamiento
- Entrenamiento del modelo
- Referencias
¿Qué es la segmentación de imágenes?
En este post clasificamos imágenes para ver si tenían cáncer. Sin embargo, lo que hacíamos era etiquetar una imagen completa, en lugar de localizar las zonas más afectadas dentro de la imagen.
Con la segmentación semántica etiquetamos cada uno de los píxeles de la imagen. De esta forma, conseguimos detectar un objeto dentro de la imagen. En la imagen superior [1] vemos cada uno de los objetos segmentados por un color distinto según la clase.
Cáncer de mama triple negativo
El cáncer de mama triple negativo es uno de los tipos de cáncer de mama más agresivos. A diferencia de otros tipos de cáncer de mama, el cáncer de mama triple negativo se propaga mucho más rápido y tiene peores pronósticos. También tiene más probabilidades de reaparecer después del tratamiento. Por estos motivos, la segmentación de células puede ayudar a estudiar la enfermedad, contando el número de células presentes en una imagen para ver la evolución y analizando el tipo de célula.
A lo largo de este post realizaremos segmentación semántica de células cancerosas utilizando Keras y la librería segmentation-models
. Además, usaremos la arquitectura U-Net, que funciona muy bien a la hora de trabajar con imágenes médicas.
Descripción del dataset
El dataset [2] que vamos a usar está formado por 50 imágenes histológicas pertenecientes a 11 pacientes con cáncer de mama triple negativo.
Con respecto al análisis exploratorio, por cada una de las 11 pacientes, se han tomado desde tres hasta ocho trozos (patches) de la imagen histológica, con una resolución de 512 × 512 cada uno. Las imágenes han sido seleccionadas por tres expertos de forma heterogénea, es decir, incluyen zonas del tejido con poca presencia celular, y otras regiones donde hay una mayor cantidad de células cancerosas invasivas. De esta forma, la variabilidad de los datos es mucho mayor, ya que todas las pacientes tienen el mismo tipo de cáncer. Cada una de las imágenes tiene una máscara binaria asociada para indicar la localización de las células. En este problema existirán dos clases: fondo (background) y célula, por lo que estaremos ante un problema de segmentación binaria. Todas las células del conjunto de datos son cancerosas.
En primer lugar instalamos los paquetes necesarios en Google Colab:
!pip install keras-unet
!pip install keras==2.3.1
!pip install tensorflow==2.1.0
!pip install keras_applications==1.0.8
!pip install image-classifiers==1.0.0
!pip install efficientnet==1.0.0
!pip install segmentation-models
!nvidia-smi
from keras_unet.utils import plot_imgs, plot_segm_history
from segmentation_models import Unet, FPN, Linknet, PSPNet
from segmentation_models import get_preprocessing
from segmentation_models.losses import bce_jaccard_loss
from segmentation_models.metrics import iou_score, IOUScore, FScore
from sklearn.model_selection import train_test_split
from keras.callbacks import ModelCheckpoint
from keras.preprocessing.image import ImageDataGenerator
from keras.optimizers import Adam
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import os
import h5py
import cv2
Carga de datos y preprocesamiento
En este apartado cargamos los datos en vectores de NumPy, mostramos las imágenes y sus máscaras, y separamos los datos en los conjuntos de entrenamiento, validación y prueba. Además, implementamos el generador de Keras para cargar las imágenes con el preprocesamiento ya hecho.
images = np.load("/content/drive/My Drive/data/images.npy")
masks = np.load("/content/drive/My Drive/data/masks.npy")
print(images.shape)
print(masks.shape)
plot_imgs(org_imgs=images, mask_imgs=masks, nm_img_to_plot=3, figsize=6)
X_train, X_test, y_train, y_test = train_test_split(images, masks, test_size=20, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_test, y_test, test_size=10, random_state=42)
class KerasGenerator:
def __init__(self, X, y, batch_size,
preprocessing=None,
data_augmentation=None,
seed=1
):
self.X = X
self.y = y
self.batch_size = batch_size
self.num_images = self.X.shape[0]
self.data_augmentation = data_augmentation
self.preprocessing = preprocessing
self.seed = seed
def generate_image_batch(self):
while True:
for i in np.arange(0, self.num_images, self.batch_size):
# Opcional: aplicamos data augmentation
if self.data_augmentation is not None:
seed = np.random.randint(1, self.seed, size=1)
images = self.X[i: i + self.batch_size]
masks = self.y[i: i + self.batch_size] / 255
genimages = self.data_augmentation.flow(images, masks, batch_size=self.batch_size, seed=seed)
gentests = self.data_augmentation.flow(masks, images, batch_size=self.batch_size, seed=seed)
images = genimages.next()
masks = gentests.next()
images = self.preprocessing(images[0])
masks = masks[0]
else:
images = self.preprocessing(self.X[i: i + self.batch_size])
masks = self.y[i: i + self.batch_size] / 255
yield (images, masks)
Entrenamiento del modelo
Para el entrenamiento del modelo, usaremos la arquitectura U-Net. La arquitectura U-Net es de tipo encoder-decoder, donde se codifica la imagen de entrada en primer lugar, reduciendo la dimensión de las imágenes. A continuación, se aumenta el tamaño de la imagen mediante deconvoluciones en la etapa decodificadora. Finalmente se genera el mapa de segmentación que indica la localización de las células. Una de las principales ventajas de U-Net es que no es necesario disponer de grandes cantidades de datos para que el modelo se entrene adecuadamente, ahorrando bastante esfuerzo. Normalmente, no suelen existir grandes cantidades en datasets de imágenes médicas.
A continuación, crearemos las constantes necesarias para el entrenamiento. Usaremos el backbone inceptionv3
de la librería segmentation-models
. Un backbone modifica la arquitectura Inception para seguir una estructura similar a U-Net.
BACKBONE = 'inceptionv3'
BATCH_SIZE = 2
LR = 0.001
EPOCHS = 80
# Utilizamos el preprocesamiento automático de la librería
preprocess_input = get_preprocessing(BACKBONE)
# Definimos el modelo de U-Net con los pesos de ImageNet para aplicar transferencia de aprendizaje
model = Unet(BACKBONE, encoder_weights='imagenet')
aug = ImageDataGenerator(horizontal_flip=True,
vertical_flip=True,
rotation_range=90,
zoom_range=[0.5, 1.0]
)
# Definimos el generador de entrenamiento y validación
trainGen = KerasGenerator(X_train, y_train, BATCH_SIZE, preprocessing=preprocess_input, data_augmentation=aug, seed=42)
valGen = KerasGenerator(X_val, y_val, BATCH_SIZE, preprocessing=preprocess_input)
model_filename = '/content/drive/My Drive/Colab Notebooks/models/unet_inceptionv3.h5'
callback_checkpoint = ModelCheckpoint(
model_filename,
verbose=1,
monitor='val_loss',
save_best_only=True,
)
optim = Adam(LR)
# Compilamos el modelo de Keras con el optimizador, la función de pérdida y las métricas definidas
model.compile(optimizer=optim, loss=bce_jaccard_loss, metrics=[iou_score, FScore()])
H = model.fit(trainGen.generate_image_batch(),
steps_per_epoch=trainGen.num_images * 2 // BATCH_SIZE,
validation_data=valGen.generate_image_batch(),
validation_steps=valGen.num_images // BATCH_SIZE,
epochs=EPOCHS,
max_queue_size=BATCH_SIZE * 2,
verbose=1,
callbacks=[callback_checkpoint]
)
model.load_weights(model_filename)
predictions = model.predict(preprocess_input(X_test))
plot_imgs(org_imgs=X_test, mask_imgs=y_test, pred_imgs=predictions, nm_img_to_plot=10, figsize=6)
Vemos que las máscaras de segmentación generadas son muy parecidas a las máscaras reales. Si superponemos la imagen de entrada con las máscaras generadas, vemos la localización de las células en las imágenes.
metrics = [IOUScore(), FScore()]
scores = model.evaluate(preprocess_input(X_test), y_test / 255)
print("Loss: {:.5}".format(scores[0]))
for metric, value in zip(metrics, scores[1:]):
print("mean {}: {:.5}".format(metric.__name__, value))
La intersección sobre la unión mide la similitud entre dos conjuntos. En este caso comparamos si los píxeles de la máscara real coinciden con los de la máscara generada. Los valores de la métrica están en el intervalo $[0, 1]$ donde un valor de 1 significa que la máscara generada coincide exactamente con la máscara real. En general, valores superiores a 0.5 son buenos resultados.
$$IoU = \frac{A \cap B}{A \cup B}$$El coeficiente de Dice o F1 score es otra de las medidas más utilizadas. Es muy similar a IoU: se multiplica por dos el área de la intersección y se divide por la suma del área de las dos máscaras. La métrica también devuelve valores comprendidos en el intervalo $[0, 1]$, midiendo la similitud entre las dos máscaras.
$$F1 = \frac{2 \cdot |A \cap B|}{|A| + |B|}$$Con los resultados anteriores, vemos que nuestro modelo generaliza bien ante nuevos datos de entrada, ya que obtenemos una media de 0.68 en IoU y 0.81 de F1-score.
plt.style.use("ggplot")
plt.figure()
plt.plot(H.history['iou_score'])
plt.plot(H.history['val_iou_score'])
plt.title("Evolución IOU")
plt.xlabel("Epoch")
plt.ylabel("IOU")
plt.legend(['Train', 'Validation'], loc='lower right')
plt.show()
plt.figure()
plt.plot(H.history['loss'])
plt.plot(H.history['val_loss'])
plt.ylim((0, 3))
plt.title('Pérdida del modelo')
plt.ylabel('Pérdida')
plt.xlabel('Época')
plt.legend(['Train', 'Validation'], loc='upper right')
plt.show()
Vemos que tenemos un modelo que ha aprendido muy bien durante el entrenamiento y con poco sobreajuste. Vemos que con U-Net se obtienen muy buenos resultados con pocas imágenes de entrada, logrando resolver el problema propuesto en este post.
Referencias
[1] F.-F. Li, J. Johnson, & S. Yeung, "Lecture 11: Detection and Segmentation," 95 (n.d.).
[2] P. Naylor, M. Laé, F. Reyal, & T. Walter, "Segmentation of Nuclei in Histopathology Images by Deep Regression of the Distance Map," IEEE Transactions on Medical Imaging 38:448–459 (2019). https://doi.org/10.1109/TMI.2018.2865709.