Differential Phase Contrast
Differential Phase Contrast Microscopy is a Computational Microscopy technique that uses partial coherent sources to illuminate a sample at various angles (oblique illumination). The angle determines the illumination NA which contributes to the final resolution. Throughout this tutorial we are going to revise the theory and implementation of DPC using OpenUC2! The tutorial will explain how to build your own DPC setup and we provide with the reconstruction algorithm given the physical parameters (based on Waller's Lab reconstruction algorithm).
Weak Object Transfer Function
Condenser lens
Tutorial: DPC setup
Materials needed:
- LED array (4x4)
- Hikrobot Camera (MV-CE060-10UC) with USB cable (Hikrobot Camera Software installation)
- ESP32 Module
- XYZ sample mount stage
- Microscope Objective (0.25 NA x10 )
- Motorized Linear stage
- Non-Kinematic Mirror
- Positive lens with 50 mm focal length
- Five empty cubes
- 15 base plates
- Tube lens (with camera adapter)
Diagram:
Instructions for assembling the DPC setup:
Step 1: Download Imswitch and the ESP32 microcontroller drivers
Once the drivers are installed you can visit (youseetoo.github.io) to test the LED array pattern sequences.
Step 2: Mount the LED array
Mount the LED array into the LED array base plate and insert it in a cube as shown.
Step 3: Build the DPC setup
Substep 1
Build the camera module as shown. It comprises of a tube lens and a Hikrobot Camera. Adjust the screw which binds the camera to the camera base plate to get the right distance between the camera and the tube lens.
Substep 2
Insert the non-kinematic mirror, the microscope objective in the fixed mount and the XYZ stage accordingly.
Substep 3
Build the illumination module which comprises of the LED array and the condenser lens as shown.
Substep 4
Finally, on top of the module built in substep 2 add the illimination module.
Step 4: Adjust the Source-sample distance
First, adjust the distance between the LED array and the condenser lens by placing them a focal distance (f = 50 mm) apart. This assures the plane wave illumination. Then, adjust the XYZ to the central positions. Adjust the Microscope objective position so that it matches roughly the working distance.
Step 5: Focus on the sample
Use Imswitch to turn one of the central LEDs, place a test sample to focus on it by coarse moving the microscope objective and finely tuning the height using the XYZ stage. Once it is in focus, adjust the distance from the condenser to the sample to be the focal length (f = 50 mm). In this geometry the LED array dimensions are near the match illumination condition. Hence, some LEDs illuminate at the objective NA (NAi = NAobj).
Note: If your sample is transparent be careful not to crash the sample with the microscope objective! For more information about this experimental setup look at: 3D differential phase-contrast microscopy with computational illumination using an LED array.
Example of illuminating sample with one half circle illumination. We should be able to see the phase gradient using oblique illumination. In the figure we can compare a defocused and focused image of a cheek cells sample.
Step 6: Run the ImDPC experiment!
Once you have focused on the sample, adjust the desired FoV. Now you are set. Click Start on the DPC widget!
Congrats! You have created a DPC microscope with OpenUC2!
DPC Images
Using the reconstruction algorithm we can retrieve the phase of the sample.
First test with the OpenUC2-DPC setup:
In the animation you can compare the contrast that we can get with brightfield illumination and the DPC reconstruction generated by the four images taken with the half circle illumination.
Taking a series of DPC images at different focal planes. Cropped DPC image of Unknown cells (top) and Cheek cells (bottom) captured with 0.25 NA microscope objective with 10x magnification.
Left:Cropped DPC image captured with 0.17 NA microscope objective with 4x magnification.
Reconstruction algorithm (Waller-Lab)
The reconstruction algorithm works with the development of the Weak Object Transfer Function (WOTF). Using the code implemented by Waller (Waller-Lab/DPC), we are able to reconstruct the absorption and phase of the samples. Here we explain each step and implementation of the code using Imswitch.
We are going to revise each part of the code and understand it.
Acquisition
We need four images corresponding to each half-circle illumination pattern. With a good exposure time for the camera to reduce noise. In the figure we can see an example of the four captured DPC images.
We can correct the images using flatfield correction. Flatfield correction consists on taking an image without the sample, then we take the image to be corrected and divided by the flatfield image. This enables us to get rid of noise like dust on the camera, for instance.
The code
The code consist of a Jupyter notebook and one python script.
Python script: dpc_algorithm.py
This script contains the core algorithm to solve the DPC problem and from the four acquired images retrieve the phase.
import numpy as np
from scipy.ndimage import uniform_filter
pi = np.pi
naxis = np.newaxis
F = lambda x: np.fft.fft2(x)
IF = lambda x: np.fft.ifft2(x)
def pupilGen(fxlin, fylin, wavelength, na, na_in=0.0):
pupil = np.array(fxlin[naxis, :]**2+fylin[:, naxis]**2 <= (na/wavelength)**2)
if na_in != 0.0:
pupil[fxlin[naxis, :]**2+fylin[:, naxis]**2 < (na_in/wavelength)**2] = 0.0
return pupil
def _genGrid(size, dx):
xlin = np.arange(size, dtype='complex128')
return (xlin-size//2)*dx
class DPCSolver:
def __init__(self, dpc_imgs, wavelength, na, na_in, pixel_size, rotation, dpc_num=4):
self.wavelength = wavelength
self.na = na
self.na_in = na_in
self.pixel_size = pixel_size
self.dpc_num = 4
self.rotation = rotation
self.fxlin = np.fft.ifftshift(_genGrid(dpc_imgs.shape[-1], 1.0/dpc_imgs.shape[-1]/self.pixel_size))
self.fylin = np.fft.ifftshift(_genGrid(dpc_imgs.shape[-2], 1.0/dpc_imgs.shape[-2]/self.pixel_size))
self.dpc_imgs = dpc_imgs.astype('float64')
self.normalization()
self.pupil = pupilGen(self.fxlin, self.fylin, self.wavelength, self.na)
self.sourceGen()
self.WOTFGen()
def setTikhonovRegularization(self, reg_u = 1e-6, reg_p = 1e-6):
self.reg_u = reg_u
self.reg_p = reg_p
def normalization(self):
for img in self.dpc_imgs:
img /= uniform_filter(img, size=img.shape[0]//2)
meanIntensity = img.mean()
img /= meanIntensity # normalize intensity with DC term
img -= 1.0 # subtract the DC term
def sourceGen(self):
self.source = []
pupil = pupilGen(self.fxlin, self.fylin, self.wavelength, self.na, na_in=self.na_in)
for rotIdx in range(self.dpc_num):
self.source.append(np.zeros((self.dpc_imgs.shape[-2:])))
rotdegree = self.rotation[rotIdx]
if rotdegree < 180:
self.source[-1][self.fylin[:, naxis]*np.cos(np.deg2rad(rotdegree))+1e-15>=
self.fxlin[naxis, :]*np.sin(np.deg2rad(rotdegree))] = 1.0
self.source[-1] *= pupil
else:
self.source[-1][self.fylin[:, naxis]*np.cos(np.deg2rad(rotdegree))+1e-15<
self.fxlin[naxis, :]*np.sin(np.deg2rad(rotdegree))] = -1.0
self.source[-1] *= pupil
self.source[-1] += pupil
self.source = np.asarray(self.source)
def WOTFGen(self):
self.Hu = []
self.Hp = []
for rotIdx in range(self.source.shape[0]):
FSP_cFP = F(self.source[rotIdx]*self.pupil)*F(self.pupil).conj()
I0 = (self.source[rotIdx]*self.pupil*self.pupil.conj()).sum()
self.Hu.append(2.0*IF(FSP_cFP.real)/I0)
self.Hp.append(2.0j*IF(1j*FSP_cFP.imag)/I0)
self.Hu = np.asarray(self.Hu)
self.Hp = np.asarray(self.Hp)
def solve(self, xini=None, plot_verbose=False, **kwargs):
dpc_result = []
AHA = [(self.Hu.conj()*self.Hu).sum(axis=0)+self.reg_u, (self.Hu.conj()*self.Hp).sum(axis=0),\
(self.Hp.conj()*self.Hu).sum(axis=0) , (self.Hp.conj()*self.Hp).sum(axis=0)+self.reg_p]
determinant = AHA[0]*AHA[3]-AHA[1]*AHA[2]
for frame_index in range(self.dpc_imgs.shape[0]//self.dpc_num):
fIntensity = np.asarray([F(self.dpc_imgs[frame_index*self.dpc_num+image_index]) for image_index in range(self.dpc_num)])
AHy = np.asarray([(self.Hu.conj()*fIntensity).sum(axis=0), (self.Hp.conj()*fIntensity).sum(axis=0)])
absorption = IF((AHA[3]*AHy[0]-AHA[1]*AHy[1])/determinant).real
phase = IF((AHA[0]*AHy[1]-AHA[2]*AHy[0])/determinant).real
dpc_result.append(absorption+1.0j*phase)
return np.asarray(dpc_result)
Jupyer notebook: main_dpc.ipynb
With this Jupyter notebook you can test the DPC reconstruction algorithm using your own images!
Import Modules
%load_ext autoreload
%autoreload 2
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt
from os import listdir
from skimage import io
from mpl_toolkits.axes_grid1 import make_axes_locatable
from dpc_algorithm import DPCSolver
Load DPC Measurements
data_path = "../sample_data/" #INSERT YOUR DATA PATH HERE
image_list = listdir(data_path)
image_list = [image_file for image_file in image_list if image_file.endswith(".tif")]
image_list.sort()
dpc_images = np.array([io.imread(data_path+image_list[image_index]) for image_index in range(len(image_list))])
#plot first set of measured DPC measurements
f, ax = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6))
for plot_index in range(4):
plot_row = plot_index//2
plot_col = np.mod(plot_index, 2)
ax[plot_row, plot_col].imshow(dpc_images[plot_index], cmap="gray",\
extent=[0, dpc_images[0].shape[-1], 0, dpc_images[0].shape[-2]])
ax[plot_row, plot_col].axis("off")
ax[plot_row, plot_col].set_title("DPC {:02d}".format(plot_index))
plt.show()
Output (example):
Set System Parameters
wavelength = 0.514 #micron
mag = 40.0
na = 0.40 #numerical aperture
na_in = 0.0
pixel_size_cam = 6.5 #pixel size of camera
dpc_num = 4 #number of DPC images captured for each absorption and phase frame
pixel_size = pixel_size_cam/mag
rotation = [0, 180, 90, 270] #degree
DPC Absorption and Phase Retrieval
Initialize DPC Solver
dpc_solver_obj = DPCSolver(dpc_images, wavelength, na, na_in, pixel_size, rotation, dpc_num=dpc_num)
Visualize Source Patterns
#plot the sources
max_na_x = max(dpc_solver_obj.fxlin.real*dpc_solver_obj.wavelength/dpc_solver_obj.na)
min_na_x = min(dpc_solver_obj.fxlin.real*dpc_solver_obj.wavelength/dpc_solver_obj.na)
max_na_y = max(dpc_solver_obj.fylin.real*dpc_solver_obj.wavelength/dpc_solver_obj.na)
min_na_y = min(dpc_solver_obj.fylin.real*dpc_solver_obj.wavelength/dpc_solver_obj.na)
f, ax = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6))
for plot_index, source in enumerate(list(dpc_solver_obj.source)):
plot_row = plot_index//2
plot_col = np.mod(plot_index, 2)
ax[plot_row, plot_col].imshow(np.fft.fftshift(dpc_solver_obj.source[plot_index]),\
cmap='gray', clim=(0,1), extent=[min_na_x, max_na_x, min_na_y, max_na_y])
ax[plot_row, plot_col].axis("off")
ax[plot_row, plot_col].set_title("DPC Source {:02d}".format(plot_index))
ax[plot_row, plot_col].set_xlim(-1.2, 1.2)
ax[plot_row, plot_col].set_ylim(-1.2, 1.2)
ax[plot_row, plot_col].set_aspect(1)
Output (example):
Visualize Weak Object Transfer Functions
#plot the transfer functions
f, ax = plt.subplots(2, 4, sharex=True, sharey=True, figsize = (10, 4))
for plot_index in range(ax.size):
plot_row = plot_index//4
plot_col = np.mod(plot_index, 4)
divider = make_axes_locatable(ax[plot_row, plot_col])
cax = divider.append_axes("right", size="5%", pad=0.05)
if plot_row == 0:
plot = ax[plot_row, plot_col].imshow(np.fft.fftshift(dpc_solver_obj.Hu[plot_col].real), cmap='jet',\
extent=[min_na_x, max_na_x, min_na_y, max_na_y], clim=[-2., 2.])
ax[plot_row, plot_col].set_title("Absorption WOTF {:02d}".format(plot_col))
plt.colorbar(plot, cax=cax, ticks=[-2., 0, 2.])
else:
plot = ax[plot_row, plot_col].imshow(np.fft.fftshift(dpc_solver_obj.Hp[plot_col].imag), cmap='jet',\
extent=[min_na_x, max_na_x, min_na_y, max_na_y], clim=[-.8, .8])
ax[plot_row, plot_col].set_title("Phase WOTF {:02d}".format(plot_col))
plt.colorbar(plot, cax=cax, ticks=[-.8, 0, .8])
ax[plot_row, plot_col].set_xlim(-2.2, 2.2)
ax[plot_row, plot_col].set_ylim(-2.2, 2.2)
ax[plot_row, plot_col].axis("off")
ax[plot_row, plot_col].set_aspect(1)
Output (example):
Solve DPC Least Squares Problem
#parameters for Tikhonov regurlarization [absorption, phase] ((need to tune this based on SNR)
dpc_solver_obj.setTikhonovRegularization(reg_u = 1e-1, reg_p = 5e-3)
dpc_result = dpc_solver_obj.solve()
_, axes = plt.subplots(1, 2, figsize=(10, 6), sharex=True, sharey=True)
divider = make_axes_locatable(axes[0])
cax_1 = divider.append_axes("right", size="5%", pad=0.05)
plot = axes[0].imshow(dpc_result[0].real, clim=[-0.15, 0.02], cmap="gray", extent=[0, dpc_result[0].shape[-1], 0, dpc_result[0].shape[-2]])
axes[0].axis("off")
plt.colorbar(plot, cax=cax_1, ticks=[-0.15, 0.02])
axes[0].set_title("Absorption")
divider = make_axes_locatable(axes[1])
cax_2 = divider.append_axes("right", size="5%", pad=0.05)
plot = axes[1].imshow(dpc_result[0].imag, clim=[-1.0, 3.0], cmap="gray", extent=[0, dpc_result[0].shape[-1], 0, dpc_result[0].shape[-2]])
axes[1].axis("off")
plt.colorbar(plot, cax=cax_2, ticks=[-1.0, 3.0])
axes[1].set_title("Phase")
Output (example):