Binary operations are an important class of functions to modify mask images (composed of 0's and 1's) and that are crucial when working segmenting images.
Let us first import the necessary modules:
import numpy as np
import matplotlib.pyplot as plt
plt.gray();
import skimage.io as io
from skimage.external.tifffile import TiffFile
import skimage.morphology as skm
import skimage.filters as skf
And we relaod the image from the last chapter and apply some thresholding to it:
#load image
data = TiffFile('Data/30567/30567.tif')
image = data.pages[3].asarray()
image = skf.rank.median(image,selem=np.ones((2,2)))
image_otsu_threshold = skf.threshold_otsu(image)
image_otsu = image > image_otsu_threshold
plt.imshow(image_otsu);
Binary operations assign to each pixel a value depending on it's neighborhood. For example we can erode or dilate the image using an area of radius 5. Erosion: If a white pixel has a black neighbor in its region it becomes black (erode). Dilation: any black pixel which as a white neighbour becomes white:
image_erode = skm.binary_erosion(image_otsu, selem = skm.disk(1))
image_dilate = skm.binary_dilation(image_otsu, selem = skm.disk(10))
plt.figure(figsize=(15,10))
plt.subplot(1,3,1)
plt.imshow(image_otsu,cmap = 'gray')
plt.title('Original')
plt.subplot(1,3,2)
plt.imshow(image_erode,cmap = 'gray')
plt.title('Erode')
plt.subplot(1,3,3)
plt.imshow(image_dilate,cmap = 'gray')
plt.title('Dilate');
image_erode1 = skm.binary_erosion(image_otsu, selem = skm.disk(1))
image_erode1b = skm.binary_erosion(image_otsu, selem = skm.disk(5))
image_erode2 = skm.binary_erosion(image_otsu, selem = skm.disk(10))
plt.subplot(1,3,1)
plt.imshow(image_erode1,cmap = 'gray')
plt.subplot(1,3,2)
plt.imshow(image_erode1b,cmap = 'gray')
plt.subplot(1,3,3)
plt.imshow(image_erode2,cmap = 'gray')
If one is only interested in the path of those shapes, one can also thin them to the maximum:
plt.figure(figsize=(10,10))
plt.imshow(skm.skeletonize(image_otsu));
Those operations can also be combined to "clean-up" an image. For example one can first erode the image to suppress isoltated pixels, and then dilate it again to restore larger structures to their original size. After that, the thinning operation gives a better result:
image_open = skm.binary_opening(image_otsu, selem = skm.disk(2))
image_thin = skm.skeletonize(image_open)
plt.figure(figsize=(15,15))
plt.subplot(2,1,1)
plt.imshow(image_open)
plt.subplot(2,1,2)
plt.imshow(image_thin);
The result of the segmentation is ok but we still have nuclei which are broken or not clean. Let's see if we can achieve a better result using another tool: region properties
from skimage.measure import label, regionprops
# MZ: labeling and region properties
# you have sth to segment (a mask); you want to mesure them individually -> needs labeling
# 1 object for all pixels linked together, and label it (connected components)
When using binary masks, one can make use of functions that detect all objects (connected regions) in the image and calculate a list of properties for them. Using those properties, one can filer out unwanted objects more easily.
Thanks to this additional tool, we can now use the local thresholding method which preserved better all the nuclei but generated a lot of noise:
image_local_threshold = skf.threshold_local(image,block_size=51)
image_local = image > image_local_threshold
plt.figure(figsize=(10,10))
plt.imshow(image_local);
As the image id very noisy, there are a large number of small white regions, and applying the region functions on it will be very slow. So we first do some filtering and remove the smallest objects:
# MZ: still lot of noise !
# remove really small pixels with erosion
# to harsh erosion -> will remove also the patterns of interest, so only soft erosion
image_local_eroded = skm.binary_erosion(image_local, selem= skm.disk(1))
plt.figure(figsize=(10,10))
plt.imshow(image_local_eroded);
To measure the properties of each region, we need a lablled image, i.e. an image in which each individual object is attributed a number. This is achieved usin the skimage.measure.label() function.
image_labeled = label(image_local_eroded)
# MZ: check all neighbors
#code snippet to make a random color scale
vals = np.linspace(0,1,256)
np.random.shuffle(vals)
cmap = plt.cm.colors.ListedColormap(plt.cm.jet(vals))
plt.figure(figsize=(10,10)) # MZ: to have bigger figure
plt.imshow(image_labeled,cmap = cmap);
image_labeled
# MZ: it is again an array
image_labeled.max()
And now we can measure all the objects' properties
# MZ: now that we have regions -> we can use regionprops
# measure differences within each regions
# (we will get properties for each of the colored regions here above)
our_regions = regionprops(image_labeled)
len(our_regions)
We see that we have a list of 2902 regions. We can look at one of them more in detail and check what attributes exist:
# MZ: output is a list of structures, look at 1 element
our_regions[10]
# MZ: each region as a set of measurements associated with it
#dir(our_regions[10])
There are four types of information:
Let us look at one region:
# MZ: a lot of other measurements (e.g. eccentricity, etc.)
our_regions[706].area
# MZ: extract the image region that corresponds to the label
our_regions[706].image
print(our_regions[706].area)
print(our_regions[706].coords)
plt.imshow(our_regions[706].image);
Using the coordinates information we can then for example recreate an image that contains only that region:
our_regions[706].coords
#create a zero image
newimage = np.zeros(image.shape)
#fill in using region coordinates
newimage[our_regions[706].coords[:,0],our_regions[706].coords[:,1]] = 1
#plot the result
plt.imshow(newimage);
In general, one has an idea about the properties of the objects that are interesting. For example, here we know that objects contain at least several tens of pixels. Let us recover all the areas and look at their distributions:
areas = [x.area for x in our_regions]
plt.hist(areas)
plt.show()
We see that we have a large majority of regions that are very small and that we can discard. Let's create a new image where we do that:
#create a zero image
newimage = np.zeros(image.shape) # MZ: create 0-array, and then put 1 onlx where area > 200 (clean smaller stuff)
#fill in using region coordinates
for x in our_regions:
if x.area>200:
newimage[x.coords[:,0],x.coords[:,1]] = 1
#plot the result
plt.imshow(newimage)
# MZ: create a new image containing only the regions that have area > 200
We see that we still have some spurious signal. We can measure again properties for the remaining regions and try to find another parameter for seleciton:
newimage_lab = label(newimage)
our_regions2 = regionprops(newimage_lab)
Most of our regions are circular, a property measures by the eccentricity. We can verifiy if we have outliers for that parameter:
plt.hist([x.eccentricity for x in our_regions2]);
Let's discard regions that are too oblong (>0.8):
# MZ: now create a new image to clean up using eccentricity
#create a zero image
newimage = np.zeros(image.shape)
#fill in using region coordinates
for x in our_regions2:
if x.eccentricity<0.8:
newimage[x.coords[:,0],x.coords[:,1]] = 1
#plot the result
plt.imshow(newimage);
This is a success! We can verify how good the segmentation is by superposing it to the image. A trick to superpose a mask on top of an image without obscuring the image, is not set all 0 elements of the mask to np.nan
.
newimage[newimage == 0] = np.nan
plt.figure(figsize=(10,10))
plt.imshow(image,cmap = 'gray')
plt.imshow(newimage,alpha = 0.4,cmap = 'Reds', vmin = 0, vmax = 2);