Polar Distribution Score

Jan 26, 2023 | Knowledge

When looking at the microscopy images of ER exit sites we noticed that after a certain perturbation they change the distribution around the nuclei. ER exit sites in normal cells are more concentrated on one side. After the perturbation, the ER exit sites uniformly distribute around the nuclei. We wanted to quantify this phenotype, so I developed a simple algorithm to express the uniformity of ER exit sites distribution with a single number.

A theoretic example

Generate three different samples of angles with mean angle 0. I use von Mises distribution, which is a continuous probability distribution on the unit circle. It may be considered as a circular analogue of the normal distribution. Then calculate an 8-bin, normalized histogram of angles. The Polar Distribution Score is calculated as the difference the sample of angles and uniform distribution. The difference is expressed as the absolute sum of histogram differences.

import numpy as np
import matplotlib.pyplot as plt
fig,ax = plt.subplots(2,3,figsize=(15,10),facecolor="w")
# define histogram bins and radius
bins=np.linspace(-np.pi,np.pi,9)
r=np.random.uniform(0.2,0.8,100)
# Iterate over populations
for i,k in enumerate([0,1,10]):
# Angles are sampled from von Mises distribution:
# mu - mean angle, kappa - spread (>=0), size - sample size
theta=np.random.vonmises(mu=0, kappa=k, size=100)
# Plot on polar projection.
ax[0,i].remove()
ax[0,i] = fig.add_subplot(2, 3,i+1, projection = 'polar')
c = ax[0,i].scatter(theta, r, alpha=0.75)
ax[0,i].set_ylim((0,1))
ax[0,i].set_title(f"Mean angle 0, spread {k}")
# Plot histogram
h = ax[1,i].hist(theta, bins=bins, density=True)
ax[1,i].set_ylim((0,1))
# Bin density of uniform distribution is 1/(8 * bin_width)
bin_width = np.diff(bins)[0]
uniform_hist = 1/(8 * bin_width)
ax[1,i].axhline(uniform_hist, c="r")
# PDS is the absolute sum of differences
pds = np.sum(np.abs(h[0]-uniform_hist))
ax[1,i].text(0, 0.9, f"PDS: {pds:.2f}", ha="center")

A practical demonstration

Let’s demonstrate this on a real life example. I’ll take images above, segment the cells and ER exit sites. Then I’ll convert the cartesian coordinates to polar and calculate the PDS.

from skimage import io
from skimage.filters import threshold_local, gaussian, threshold_otsu
from skimage.segmentation import clear_border
from skimage.measure import label, regionprops_table
import pandas as pd 
img_disrupted_eres=io.imread("pds-cells-rhob-green.png")
img_normal_eres=io.imread("pds-cells-neg-green.png")
img_disrupted_eres_blurred=gaussian(img_disrupted_eres,80)
img_normal_eres_blurred=gaussian(img_normal_eres,80) 
cells_disrupted_mask=img_disrupted_eres_blurred>threshold_local(img_disrupted_eres_blurred,501,offset=0.001)
cells_normal_mask=img_normal_eres_blurred>threshold_local(img_normal_eres_blurred,501,offset=0.001)
#Eres
eres_disrupted_mask=img_disrupted_eres>threshold_otsu(img_disrupted_eres)
eres_normal_mask=img_normal_eres>threshold_otsu(img_normal_eres)
eres_disrupted=label(eres_disrupted_mask)
eres_normal=label(eres_normal_mask)
#Nuclei
img_disrupted_nuc=io.imread("pds-cells-rhob-blue.png")
img_normal_nuc=io.imread("pds-cells-neg-blue.png")
img_disrupted_nuc_blurred=gaussian(img_disrupted_nuc,15)
img_normal_nuc_blurred=gaussian(img_normal_nuc,15)
nuc_disrupted_mask=clear_border(img_disrupted_nuc_blurred>threshold_local(img_disrupted_nuc_blurred,501,offset=0.001))
nuc_normal_mask=clear_border(img_normal_nuc_blurred>threshold_local(img_normal_nuc_blurred,501,offset=0.001))
# Link cells and nuclei
cells_normal_mask=clear_border(np.logical_or(nuc_normal_mask,cells_normal_mask))
cells_disrupted_mask=clear_border(np.logical_or(nuc_disrupted_mask,cells_disrupted_mask))
cells_disrupted=label(cells_disrupted_mask)
cells_normal=label(cells_normal_mask)
nuc_disrupted=nuc_disrupted_mask*cells_disrupted
nuc_normal=nuc_normal_mask*cells_normal
# Get region properties for nuclei.
nuc_disrupted_rp=pd.DataFrame(regionprops_table(nuc_disrupted,properties=["label","centroid"])).set_index("label")
nuc_normal_rp=pd.DataFrame(regionprops_table(nuc_normal,properties=["label","centroid"])).set_index("label")
# Link ERES to cells and get region props.
eres_disrupted_rp=[]
for c in np.unique(cells_disrupted[cells_disrupted>0]):
mask=eres_disrupted*(cells_disrupted==c)
eres_rp=pd.DataFrame(regionprops_table(mask,
properties=["label","centroid"]))
eres_rp["cell"]=c
eres_disrupted_rp.append(eres_rp)
eres_disrupted_rp=pd.concat(eres_disrupted_rp).set_index("label")
eres_normal_rp=[]
for c in np.unique(cells_normal[cells_normal>0]):
mask=eres_normal*(cells_normal==c)
eres_rp=pd.DataFrame(regionprops_table(mask,
properties=["label","centroid"]))
eres_rp["cell"]=c
eres_normal_rp.append(eres_rp)
eres_normal_rp=pd.concat(eres_normal_rp).set_index("label")
# Subtract nuceli centroids to center the coordinates
eres_disrupted_rp[["centroid_centered-0","centroid_centered1"]]=eres_disrupted_rp.apply(lambda x: x[["centroid-0","centroid-1"]]-
nuc_disrupted_rp.loc[x["cell"],["centroid-0","centroid-1"]],axis=1)
eres_normal_rp[["centroid_centered-0","centroid_centered1"]]=eres_normal_rp.apply(lambda x: x[["centroid-0","centroid-1"]]-
nuc_normal_rp.loc[x["cell"],["centroid-0","centroid-1"]],axis=1)
# Convert to polar coordinates
eres_disrupted_rp["rho"] =
np.sqrt(eres_disrupted_rp["centroid_centered-1"]**2 +
eres_disrupted_rp["centroid_centered-0"]**2)
eres_disrupted_rp["phi"] =
np.arctan2(eres_disrupted_rp["centroid_centered-0"],
eres_disrupted_rp["centroid_centered-1"])
eres_normal_rp["rho"] = np.sqrt(eres_normal_rp["centroid_centered1"]**2 + eres_normal_rp["centroid_centered-0"]**2)
eres_normal_rp["phi"] = np.arctan2(eres_normal_rp["centroid_centered0"], eres_normal_rp["centroid_centered-1"])
# zero mean angle normalization
eres_disrupted_rp["phi_norm"]=(eres_disrupted_rp.groupby("cell").apply (lambda x: x["phi"]-np.angle(np.sum(np.exp([complex(0,f) for f in x["phi"]])))).to_numpy()+2*np.pi)%(2*np.pi)
eres_normal_rp["phi_norm"]=(eres_normal_rp.groupby("cell").apply(lambda x: x["phi"]-np.angle(np.sum(np.exp([complex(0,f) for f in x["phi"]])))).to_numpy()+2*np.pi)%(2*np.pi)
# histogram and pds per cell
def calculate_pds(a):
bins=np.linspace(0,2*np.pi,9)
bin_width = np.diff(bins)[0]
uniform_hist = 1/(8 * bin_width)
a=(a+2*np.pi)%(2*np.pi)
h=np.histogram(a,bins=bins, density=True)
pds = np.sum(np.abs(h[0]-uniform_hist))
return(pds)
eres_disrupted_pds=eres_disrupted_rp.groupby("cell").agg(pds=("phi_norm",calculate_pds))
eres_normal_pds=eres_normal_rp.groupby("cell").agg(pds=("phi_norm",calculate_pds)) 
# Plot
bins=np.linspace(0,2*np.pi,9)
bin_width = np.diff(bins)[0]
uniform_hist = 1/(8 * bin_width)
ncells=np.maximum(len(eres_disrupted_rp["cell"].unique()),len(eres_normal_rp["cell"].unique()))
fig,ax = plt.subplots(4,ncells,figsize=(5*ncells,22),facecolor="w")
# Iterate over disrupted cells
for i,k in enumerate(eres_disrupted_rp["cell"].unique()):
phi=eres_disrupted_rp.loc[eres_disrupted_rp["cell"]==k,"phi_norm"].to_numpy()
rho=eres_disrupted_rp.loc[eres_disrupted_rp["cell"]==k,"rho"].to_numpy()
# Plot on polar projection.
ax[0,i].remove()
ax[0,i] = fig.add_subplot(4, ncells,i+1, projection = 'polar')
c = ax[0,i].scatter(phi, rho, alpha=0.75,s=10)
ax[0,i].set_title(f"Disrupted cell {k}")
# Plot histogram
h = ax[1,i].hist(phi, bins=bins, density=True)
ax[1,i].set_ylim((0,1))
ax[1,i].axhline(uniform_hist, c="r")
# PDS is the absolute sum of differences
ax[1,i].text(np.pi, 0.9, f"PDS:
{eres_disrupted_pds.loc[k,'pds']:.2f}", ha="center")
# Iterate over normal cells
for i,k in enumerate(eres_normal_rp["cell"].unique()): 
phi=eres_normal_rp.loc[eres_normal_rp["cell"]==k,"phi_norm"].to_numpy()
rho=eres_normal_rp.loc[eres_normal_rp["cell"]==k,"rho"].to_numpy()
# Plot on polar projection.
ax[2,i].remove()
ax[2,i] = fig.add_subplot(4, ncells,i+7, projection = 'polar')
c = ax[2,i].scatter(phi, rho, alpha=0.75,s=10)
ax[2,i].set_title(f"Normal cell {k}")
# Plot histogram
h = ax[3,i].hist(phi, bins=bins, density=True)
ax[3,i].set_ylim((0,1))
ax[3,i].axhline(uniform_hist, c="r")
ax[3,i].text(np.pi, 0.9, f"PDS:{eres_normal_pds.loc[k,'pds']:.2f}", ha="center")

Disrupted cells have a lower PDS, because the distribution of ER exit sites around the nuclei is more uniform. Normal cells (rows 3 and 4 above) have higher PDS, because ER exit sites are concentrated on one edge.

The segmentation and measurements are done with scikit-image. If working with a larger dataset it would be easier to use CellProfiler to do the segmentation. Then we would import the .csv files to Jupyter, center the coordinates and do the rest.