= "../data/example_image_ch00.tif"
filename = "../data/example_model"
model_path = 5.0 radius_um
NEST
Below you’ll find everything you need to get started with NEST, from installing the package to running a complete segmentation pipeline on your own images. The typical workflow involves:
- Loading and visualizing your raw image
- Correcting illumination artifacts using CLAHE
- Dividing the image into overlapping tiles
- Segmenting nuclei with a pre‐trained CellPose model
- Stitching the tile‐level masks back into a full‐image segmentation
- Expanding nuclear labels to approximate cell boundaries
- Overlaying outlines and exporting results
Installation
Install latest from the GitHub repository:
$ pip install git+https://github.com/plezar/NEST.git
Documentation
Documentation can be found hosted on this GitHub repository’s pages.
How to use
The core inputs required to run NEST are:
Input Image
A grayscale microscopy image (TIFF or similar) containing the nuclei you wish to segment.CellPose Model
Path to a pre-trained CellPose model.Expansion Radius (µm)
The distance (in micrometers) by which to expand the nuclear masks to approximate whole-cell boundaries.
Given these inputs, NEST will:
- Load and enhance the image for uniform illumination.
- Split the image into overlapping tiles.
- Run segmentation on each tile.
- Stitch tile masks into a global mask.
- Optionally expand masks to full-cell labels.
Example Workflow for Fluorescence Microscopy Images
Reading the Input Image
We provide a sample image in the data folder. This image was acquired using a Leica Stellaris Dive system by stitching multiple 1024×1024 fields of view:
= cv2.imread(filename, cv2.IMREAD_UNCHANGED)
image
='grey')
plt.imshow(image, cmap'off')
plt.axis( plt.show()
Flat-Field Correction with CLAHE
Microscopy images often suffer from uneven illumination due to optical and sample variations. NEST uses Contrast Limited Adaptive Histogram Equalization (CLAHE) to address this:
- Local Processing: The image is divided into small tiles (matching the stitching grid).
- Histogram Equalization: Each tile’s brightness distribution is equalized independently.
- Contrast Limiting: A
clip_limit
prevents over-enhancement of noise. - Seamless Merging: Tiles are blended back together to produce a smoothly corrected image.
= clahe(image, clip_limit = 3, tile_grid_size = count(image, t = 1024, v = 0.1)) image_enh
= plt.subplots(1, 2, figsize=(8, 4))
fig, (ax1, ax2)
='grey')
ax1.imshow(image, cmap'Original image')
ax1.set_title('off')
ax1.axis(
='grey')
ax2.imshow(image_enh, cmap'CLAHE')
ax2.set_title('off')
ax2.axis(
plt.tight_layout() plt.show()
Notice that local brightness gradients are flattened while preserving fine nuclear details.
Tiling the Enhanced Image
To maintain segmentation accuracy at the edges of each tile, NEST splits the enhanced image into 128×128 pixel tiles with a 20-pixel overlap. This overlap captures nuclei that span tile borders, reducing artifacts. Larger overlap improves accuracy but increases computational load.
= split(image_enh, tile_size = (128, 128), overlap = 20)
tiles, x, y print(tiles.shape)
(361, 128, 128)
Segmentation
This example demonstrates nuclear segmentation, but the same workflow in principle can be applied to any segmentation task (membrane or cytoplasmic).
= predict(img_list = tiles,
mask = True,
use_gpu = model_path,
model_path =(0.32, 0.08))
cellpose_param'../cache/mask.npy', mask) np.save(
Using custom Cellpose model from ../data/example_model for segmentation.
no seeds found in get_masks_torch - no masks found.
= np.load('../cache/mask.npy') mask
= plt.subplots(1, 4, figsize=(16,4))
fig, axes = ['Tile 1', 'Tile 2', 'Tile 3', 'Tile 4']
titles
for ax, m, img, title in zip(axes, mask[:4], tiles[:4], titles):
= plot_outlines(m, img)
overlay
ax.imshow(overlay)'off')
ax.axis(
ax.set_title(title)
plt.tight_layout() plt.show()
Stitching
= stitch(mask, x, y, 0.2) global_mask
= plot_outlines(global_mask, image_enh, save_path="../data/nuclei_overlay.tif")
nuclei_overlay
plt.imshow(nuclei_overlay)'off')
plt.axis( plt.show()
Nuclear expansion
= get_physical_size(filename, verbose=True)
ps = expand_with_cap(
cell_labels
global_mask,=ps,
spacing=5,
fixed_expand=5
max_area_ratio )
Pixel size = 0.540 × 0.540 µm/px
Physical size = 1069.3 × 1069.3 µm
= plot_outlines(cell_labels, image_enh, save_path="../data/cell_overlay.tif")
cell_overlay
plt.imshow(cell_overlay)'off')
plt.axis( plt.show()
plot_label_mask
can be used to visualize the final segmentation mask when cells have been assigned unique labels. Here we assign random labels to each cell for visualization purposes.
= np.random.RandomState(45)
rng = np.unique(global_mask)
labels = labels[labels != 0]
labels = [f"type_{i+1}" for i in range(50)]
type_names
# build the map: each label → a random choice from type_names
= {
cell_type_map
lbl: rng.choice(type_names)for lbl in labels
}
=cell_labels,
plot_label_mask(mask=global_mask,
nuclear_mask=cell_type_map,
cell_type_map=100,
seed='../data/mask_overlay.tif') save_path
plot_channel_mask
can be used to show signal intensity for one or more channels overlaid with the segmentation mask. Here we show the GFP and DAPI channels (nuclear stain) overlaid with the segmentation mask. When only one channel is provided, the channel is shown as a heatmap. If multiple channels are provided, the channels are shown as RGB composites.
= cv2.imread("../data/example_image_ch01.tif", cv2.IMREAD_UNCHANGED)
gfp_image
=cell_labels,
plot_channel_mask(mask=gfp_image,
channel1=global_mask) nuclear_mask
=cell_labels,
plot_channel_mask(mask=gfp_image,
channel1=np.zeros_like(gfp_image),
channel2=image,
channel3=global_mask) nuclear_mask
Analysis
= compute_mfi(cell_mask=cell_labels,
mfi_df =image,
channel1=gfp_image) channel2
=(8,6))
plt.figure(figsize'mfi_ch2'], bins=50, color='white', edgecolor='grey', density=True)
plt.hist(mfi_df[= plt.gca()
ax 'mfi_ch2'].plot(kind='kde', color='red', ax=ax)
mfi_df['Per-cell MFI')
plt.xlabel('Kernel Density Estimate')
plt.ylabel(10000, color='red', linestyle='--')
plt.axvline(
plt.show()
# TO-DO: add a utility of identifying marker-positive cells based on a reference image (e.g. negative conrol) using a mixture model.
"cell_type"] = np.select(
mfi_df["mfi_ch2"] > 10000],
[mfi_df["Tumor"],
[="other")
default
plot_label_mask(cell_labels,=global_mask,
nuclear_mask=mfi_df.set_index('label')['cell_type'].to_dict(),
cell_type_map=(0,0,0),
default_color="white",
outline_color={"Tumor": (0,1,0)}) type_colors
Unsupervised: K-means clustering
To determine the optimal number of clusters (\(k\)), we use the Elbow method, which involves plotting the sum of squared distances (inertia) to the the nearest cluster center for different values of \(k\). The “elbow” point in the plot indicates the optimal \(k\).
= mfi_df[['mfi_ch1', 'mfi_ch2']].values
X = range(1, 20)
k_values = []
inertias for k in k_values:
= KMeans(n_clusters=k, random_state=0)
km
km.fit(X)
inertias.append(km.inertia_)
# Plot the “elbow” curve
=(6, 3))
plt.figure(figsizelist(k_values), inertias, marker='o')
plt.plot('Number of clusters (k)')
plt.xlabel('Inertia')
plt.ylabel(list(k_values))
plt.xticks('Elbow Method for Optimal k')
plt.title( plt.show()
Based on the above plot, we choose \(k=4\) for clustering.
= 4
k = KMeans(n_clusters=k, random_state=0).fit(X)
kmeans 'kmeans_cluster'] = kmeans.labels_ mfi_df[
We can examine the counts of the cell labels we previously assigned to each cluster:
= pd.crosstab(
contingency 'cell_type'],
mfi_df['kmeans_cluster'],
mfi_df[=['cell_type'],
rownames=['kmeans_cluster'],
colnames=True
margins
)
print(contingency)
kmeans_cluster 0 1 2 3 All
cell_type
Tumor 69 481 483 1494 2527
other 3112 891 0 7 4010
All 3181 1372 483 1501 6537
= mfi_df.set_index('label')['kmeans_cluster'].to_dict()
cluster_map
plot_label_mask(cell_labels,=global_mask,
nuclear_mask=cluster_map,
cell_type_map=(0,0,0),
default_color=18,
seed="white") outline_color
We can also examine the distribution of each feature across the clusters using violin plots:
=(6, 3))
plt.figure(figsize
sns.violinplot(="kmeans_cluster",
x="mfi_ch1",
y=mfi_df,
data="kmeans_cluster",
hue="muted",
palette=False
legend
)"K-means Cluster")
plt.xlabel("GFP MFI")
plt.ylabel(
plt.tight_layout() plt.show()
Example Workflow for H&E Images
Reading the Input Image
="../data/FixedFrozen_HE.png"
filename= np.load('../cache/FixedFrozen_MouseBrain_mask.npy')
mask = cv2.imread(filename)[...,::-1] ihc_rgb
# Call the function and collect the channels
= process_ihc_image(ihc_rgb)
ihc_rgb, ihc_e, ihc_h, ihc_d
# Plot the output channels
= plt.subplots(3, 1, figsize=(7, 6), sharex=True, sharey=True)
fig, axes = axes.ravel()
ax
0].imshow(ihc_rgb)
ax[0].set_title("Original image")
ax[
1].imshow(ihc_h)
ax[1].set_title("Hematoxylin")
ax[
2].imshow(ihc_e)
ax[2].set_title("Eosin")
ax[
for a in ax:
'off')
a.axis(
fig.tight_layout() plt.show()
We will use the deconvolved eosin image for nuclear sementation. First, we convert the image to grayscale and invert it.
= cv2.cvtColor(ihc_e.astype(np.float32), cv2.COLOR_RGB2GRAY)
gray_image = np.abs(gray_image - 1)
gray_image = (gray_image * 255).astype(np.uint8)
gray_image
='gray')
plt.imshow(gray_image, cmap'off')
plt.axis( plt.show()
= split(gray_image, tile_size = (128, 128), overlap = 20)
tiles, x, y
20], cmap='gray')
plt.imshow(tiles['off')
plt.axis(
plt.show()
# save the tiles in case you need to train a custom model
# or determine optimal parameters
# save(tiles, "FixedFrozen_MouseBrain", x, y, "/Users/mzarodniuk/Documents/SAI/NEST_workdir/FixedFrozen_MouseBrain")
= predict(img_list=tiles, use_gpu=True, model_path=None, cellpose_param=(0.4, 0), diameters=10.0)
mask '../cache/FixedFrozen_MouseBrain_mask.npy', mask) np.save(
Using default Cellpose model for nuclei segmentation.
no seeds found in get_masks_torch - no masks found.
no seeds found in get_masks_torch - no masks found.
no seeds found in get_masks_torch - no masks found.
no seeds found in get_masks_torch - no masks found.
no seeds found in get_masks_torch - no masks found.
no seeds found in get_masks_torch - no masks found.
= plt.subplots(1, 4, figsize=(16,4))
fig, axes = ['Tile 1', 'Tile 2', 'Tile 3', 'Tile 4']
titles
for ax, m, img, title in zip(axes, mask[20:24], tiles[20:24], titles):
= plot_outlines(m, img)
overlay
ax.imshow(overlay)'off')
ax.axis(
ax.set_title(title)
plt.tight_layout() plt.show()
Stitching
= stitch(mask, x, y, 0.2)
global_mask
= plot_outlines(global_mask, gray_image, save_path="../data/FixedFrozen_MouseBrain_nuclei_overlay.tif")
nuclei_overlay
plt.imshow(nuclei_overlay)'off')
plt.axis( plt.show()