Workshop Python Image Analysis

Martijn Wehrens, May 2026

Answers to exercises 03

import matplotlib.pyplot as plt
import seaborn as sns
import tifffile as tiff
import numpy as np

import skimage as sk
from scipy import stats
from scipy import ndimage

from skimage.measure import label, regionprops

def my_plot_1(img1, mycmap='gray', myfigsize=(5/2.54,5/2.54)):
    fig, ax = plt.subplots(1,1, figsize=myfigsize)
    _=plt.imshow(img1, cmap=mycmap) 
    
def my_plot_12(img1, img2, mycmap='viridis', myfigsize=(10/2.54,5/2.54)):
    # plot two images side by side
    fig, axs = plt.subplots(1,2, figsize=myfigsize)
    _ = axs[0].imshow(img1, cmap=mycmap)
    _ = axs[1].imshow(img2, cmap=mycmap)  
    plt.tight_layout()     

Creating segmentation functions

def seg_bacterium(input_img):
    """
    Segment bacteria in an image using LoG zero crossings and watershed.
    """
    # input_img = np.max(img_ecoli)-img_ecoli

    # Smooth image before Laplacian filtering
    img_gauss = sk.filters.gaussian(input_img, sigma=3)
    edges_log = sk.filters.laplace(img_gauss)
        # minmaxval = np.max(np.abs(edges_log)); plt.imshow(edges_log, cmap='bwr', vmin=-minmaxval, vmax=minmaxval) 

    # Identify LoG zero crossings as bacterial outlines
    edges_min = ndimage.minimum_filter(edges_log, footprint=sk.morphology.disk(1))
    edges_max = ndimage.maximum_filter(edges_log, footprint=sk.morphology.disk(1))
    mask_edges = np.logical_and(edges_min < 0, edges_max > 0)
        # plt.imshow(mask_edges)

    # Build a whole-colony mask from triangle thresholding
    mask_triangle = input_img > sk.filters.threshold_triangle(input_img)
    mask_triangle = sk.morphology.remove_small_objects(mask_triangle, min_size=50)
    mask_triangle = sk.morphology.dilation(mask_triangle, footprint=sk.morphology.disk(8))
        # plt.imshow(mask_triangle)

    # Restrict edges to the colony and fill enclosed bacteria
    mask_edges = np.logical_and(mask_edges, mask_triangle)
    mask_filled = ndimage.binary_fill_holes(mask_edges)
        # plt.imshow(mask_filled)

    # Erode to create separated seed regions for watershed
    mask_seeds = sk.morphology.erosion(mask_filled, footprint=sk.morphology.disk(4))
    mask_seeds = sk.measure.label(mask_seeds)
        # plt.imshow(mask_seeds)

    # Expand seeds back into the filled bacterial mask
    result = sk.segmentation.watershed(-1 * mask_filled, markers=mask_seeds, mask=mask_filled)
        # plt.imshow(result)

    return result


def seg_nuclei(input_img):
    """
    Segment nuclei in an image using triangle thresholding.
    """
    
    # Apply triangle threshold method
    thresh = sk.filters.threshold_triangle(input_img)
    mask = input_img > thresh
    
    # Remove small objects and label
    mask_filtered = sk.morphology.remove_small_objects(mask, min_size=50)
    result = sk.measure.label(mask_filtered)
    
    return result
        

# Now let's test the above functions


# Bacteria
img_ecoli = tiff.imread('../images/biological/microcolony_ecoli.tif')
mask_bacteria = seg_bacterium(np.max(img_ecoli)-img_ecoli)
_ = plt.imshow(mask_bacteria)
plt.show()

# Nuclei
img_path_KTR = '/Users/m.wehrens/Data_notbacked/2025_Py-Image-workshop_KTR-example-data/raw/Composite_KTR.tif'
img_nuclei = tiff.imread(img_path_KTR)[0, 0, 0:200, 0:200]
mask_nuclei = seg_nuclei(img_nuclei)
_ = plt.imshow(mask_nuclei)
plt.show()

Counting spots

# Read the image
img_spots = tiff.imread("../images/bioimagebook/spots.tif")
_ = plt.title("Original spots image")
_ = plt.imshow(img_spots)
plt.show()

# Create a local mean convolution kernel
a_disk     = sk.morphology.disk(5)
the_kernel = a_disk/np.sum(a_disk)

# Apply it to the image `images/bioimagebook/spots.tif`.
img_spots_convolved = ndimage.convolve(img_spots, the_kernel)

# Show the result
_ = plt.title("Result of local averaging")
_ = plt.imshow(img_spots_convolved)
plt.show()
_ = plt.title("Histogram after local averaging")
_ = plt.hist(img_spots_convolved.ravel(), bins = np.arange(0, 255, 5))
plt.show()

# Segment based on new image
threshold_triangle = sk.filters.threshold_triangle(img_spots_convolved)
mask_spots = img_spots_convolved > threshold_triangle

#  Make a plot with contours
_ = plt.title("Identified spots (using triangle algorithm)")
_ = plt.imshow(img_spots_convolved)
_ = plt.contour(mask_spots, colors='w', linewidths=1, levels=[0.5])
plt.show()

# Count the spots
regionprops_spots = regionprops(label(mask_spots))
areas_spots = np.array([r.area for r in regionprops_spots])
# Let's count a spot if area>3
print("Total nr of spots:", np.sum(areas_spots>3))

# In case we're perfectionists
# Using a relative margin of 0.2
# Similar as to what is done in Zack et al. in 1977. (https://doi.org/10.1177/25.7.7045)
img_mode = np.bincount(img_spots_convolved.ravel()).argmax()
img_max  = np.max(img_spots_convolved)
threshold_margin = (img_max-img_mode)/5
mask_spots2   = img_spots_convolved > (threshold_triangle+threshold_margin)
rprops_spots2 = regionprops(label(mask_spots2))
# And plot
_ = plt.title('Identified spots (triangle, using "Zack" margin)') 
_ = plt.imshow(img_spots_convolved)
_ = plt.contour(mask_spots2, colors='w', linewidths=1, levels=[0.5])
# add labels
for i, r in enumerate(rprops_spots2):
    minr, minc, maxr, maxc = r.bbox
    _ = plt.text(maxc+5, maxr, str(i+1), color='w')
plt.show()

Total nr of spots: 10

License plate

img_shady = tiff.imread('../images/car/chatGPT_shadybusiness_licensehigh-8bit.tif')
img_shady_inv = np.max(img_shady)-img_shady
my_plot_1(img_shady_inv, mycmap='viridis')

# Segment the symbols on the license plate

# apply triangle thresholding
thresh = sk.filters.threshold_otsu(img_shady_inv)
# get the mask
mask_shady = img_shady_inv>thresh
mask_shady_labeled = sk.measure.label(mask_shady)
props_shady = sk.measure.regionprops(mask_shady_labeled)
# retain labels that are >10, <100 in area
mask_shady_filt = np.isin(mask_shady_labeled, 
                          [p.label for p in props_shady if p.area>5 and p.area<200])

# show result
my_plot_12(mask_shady, mask_shady_filt, mycmap='gray')

# acquire labels and regionprops
mask_shady_filt_label = sk.measure.label(mask_shady_filt)
props_shady = sk.measure.regionprops(mask_shady_filt_label)

# plot every bbox separately
fig, axs = plt.subplots(1,len(props_shady), figsize=(len(props_shady)*5/2.54,5/2.54))
for i, p in enumerate(props_shady):
    minr, minc, maxr, maxc = p.bbox
    # Plot (and remove axes)
    _ = axs[i].imshow(img_shady_inv[minr:maxr, minc:maxc], cmap='magma')
    axs[i].axis('off')

Distance transform

# load images
img_path_KTR = '/Users/m.wehrens/Data_notbacked/2025_Py-Image-workshop_KTR-example-data/raw/Composite_KTR.tif'
img_nuclei = tiff.imread(img_path_KTR)[0, 0, 0:200, 0:200]

# Let's also load an image of some bacteria (Wehrens et al.)
path_img_ecoli = '../images/biological/microcolony_ecoli.tif'
img_ecoli = tiff.imread(path_img_ecoli)
img_ecoli_inv = np.max(img_ecoli)-img_ecoli # invert image

# create mask
mask_nuclei_triangle = img_nuclei > sk.filters.threshold_triangle(img_nuclei)
mask_ecoli_triangle = img_ecoli_inv > sk.filters.threshold_triangle(img_ecoli_inv)
# Perform a distance perform on the bacterial image
distance_nuclei = ndimage.distance_transform_edt(mask_nuclei_triangle)
distance_ecoli  = ndimage.distance_transform_edt(mask_ecoli_triangle)


# Show it
fig, axs = plt.subplots(1,2)
_=axs[0].imshow(distance_nuclei, cmap='magma')
_=axs[1].imshow(distance_ecoli, cmap='magma')

# Get local maxima for the nuclei
local_max_nuclei = sk.feature.peak_local_max(distance_nuclei, 
                                             min_distance = 10,
                                             threshold_abs = 3,
                                             footprint=sk.morphology.disk(10),
                                             exclude_border=False)

# Plot the nuclei picture, and plot the local maxima on top as crosses
fig, ax = plt.subplots()
_ = ax.imshow(img_nuclei, cmap='viridis')
x = local_max_nuclei[:, 1]
y = local_max_nuclei[:, 0]
_ = ax.plot(x, y, 'r+')

Thus, the local maxima correspond relatively well to individual nuclei. This could be used in further processing (watershedding) to separate them.

Another approach to identify individual nuclei is to erode the mask that was found, in the hope that for nuclei that are connected only a little, the connection gets eroded away.