import warnings
from typing import Optional, Union
import astropy.units as u
import cv2
import numpy as np
from astropy.units import Quantity
from numpy.typing import ArrayLike
from scipy.signal import fftconvolve
from ..core import Processor, Detector
from ..plot_utilities import imshow # noqa - for debugging
from ..simulation.mockdevices import XYStage, LinearStage, StaticSource
from ..utilities import project, place, Transform, get_pixel_size, patterns
from ..utilities.patterns import propagation
[docs]
class Microscope(Processor):
"""A simulated microscope with pupil-conjugate SLM.
The microscope simulates physical effects such as aberrations and noise, as well as devices typically found in a
wavefront shaping microscope: a spatial light modulator, translation stages, and a camera.
This simulation is designed to test algorithms for wavefront shaping and alignment.
The simulation takes the field at the SLM, applies the phase aberrations, and masks the field with
a pupil function corresponding to the numerical aperture of the microscope objective.
This field is then Fourier transformed to obtain the intensity point spread function,
with which the source image is convolved.
Finally, the resulting image is mapped to the camera using a magnification factor, or affine transformation matrix.
The propagation is normalized such that a pupil fully filled with a field strength of 1.0 will produce an image
that has the same total intensity as the source image.
"""
def __init__(
self,
source: Union[Detector, np.ndarray],
*,
data_shape=None,
numerical_aperture: float = 1.0,
wavelength: Quantity[u.nm],
nonlinearity: int = 1,
magnification: float = 1.0,
xy_stage=None,
z_stage=None,
immersion_refractive_index: Optional[float] = 1.0,
incident_field: Union[Detector, ArrayLike, None] = None,
incident_transform: Optional[Transform] = None,
aberrations: Union[Detector, np.ndarray, None] = None,
aberration_transform: Optional[Transform] = None,
multi_threaded: bool = True,
):
"""
Args:
source: 2-D image (must have `pixel_size` metadata), or
a detector that produces 2-D images of the original 'specimen'
data_shape: shape (size in pixels) of the output.
Default value: source.data_shape
numerical_aperture: Numerical aperture of the microscope objective
wavelength: Wavelength of the light used for imaging,
the wavelength and numerical_aperture together determine the resolution of the microscope.
nonlinearity: Exponent to which the PSF is raised. This can be used to simulate two-photon microscopy (nonlinearity=2),
or multiphoton microscopy in general (nonlinearity > 2).
magnification: Scalar magnification factor between input and output image.
Note that this factor does not affect the effective image resolution.
Increasing the magnification will just produce a zoomed-in blurred image.
The default settings for data_shape, pixel_size and magnification cause the simulated microscope
to only convolve the source image with the point-spread function (PSF) without applying any scaling.
xy_stage (XYStage): Optional stage object that can be used to move the sample laterally.
Defaults to a MockXYStage.
z_stage (Stage): Optional stage object that moves the sample up and down to focus the microscope.
Higher values are further away from the microscope objective.
Defaults to a MockStage.
immersion_refractive_index: The refractive index of the immersion medium.
incident_field: Produces 2-D complex images containing the field output of the SLM.
If no `slm_transform` is specified, the `pixel_size` attribute should
correspond to normalized pupil coordinates
(e.g. with a disk of radius 1.0, i.e. an extent of 2.0, corresponding to an NA of 1.0)
incident_transform (Optional[Transform]):
Optional Transform that transforms the phase pattern from the slm object
(in slm.pixel_size units) to normalized pupil coordinates.
Typically, the slm image is already in normalized pupil coordinates,
but this transform can be used to mimic SLM misalignment.
aberrations: 2-D image containing the phase (in radians) of aberrations observed
in the back pupil of the microscope objective, or a Detector object that automatically produces such
images. The `extent` attribute corresponds to normalized pupil coordinates. For example, with a
numerical aperture of 0.6, the extent of the image should be 1.2. If a 2-D image without pixel_size
metadata is provided, the extent is automatically set to 2.0 * numerical_aperture.
aberration_transform (Optional[Transform]):
Optional Transform that transforms the phase pattern from the aberration object
(in slm.pixel_size units) to normalized pupil coordinates.
Typically, the slm image is already in normalized pupil coordinates,
but this transform may e.g., be used to scale an aberration pattern
from extent 2.0 to 2.0 * NA.
Note:
The aberration map and slm phase map are cropped/padded to the NA of the microscope objective, and
scaled to have the same pixel resolution so that they can be added.
"""
if not isinstance(source, Detector):
if get_pixel_size(source) is None:
raise ValueError("The source must have a pixel_size attribute.")
source = StaticSource(source)
if aberrations is not None and not isinstance(aberrations, Detector):
if get_pixel_size(aberrations) is None:
aberrations = StaticSource(aberrations)
else:
aberrations = StaticSource(aberrations, pixel_size=get_pixel_size(aberrations))
super().__init__(source, aberrations, incident_field, multi_threaded=multi_threaded)
self._magnification = magnification
self._data_shape = data_shape if data_shape is not None else source.data_shape
self.numerical_aperture = numerical_aperture
self.nonlinearity = nonlinearity
self.aberration_transform = aberration_transform
self.slm_transform = incident_transform
self.wavelength = wavelength.to(u.nm)
self.immersion_refractive_index = immersion_refractive_index
self.oversampling_factor = 2.0
self.xy_stage = xy_stage or XYStage(0.1 * u.um, 0.1 * u.um)
self.z_stage = z_stage or LinearStage(0.1 * u.um)
self._psf = None
def _fetch(
self,
source: np.ndarray,
aberrations: np.ndarray, # noqa
incident_field: np.ndarray,
) -> np.ndarray:
"""Updates the image on the camera sensor.
To compute the image:
* First trigger the source, slm, and aberration sources
* Then read the corresponding images.
* Combines the slm and aberration images to compute the PSF
* Crop the source image and upsample if needed
* Convolve the source image with the PSF.
* Compute the magnified and cropped image on the camera.
Args:
source: The source image (specimen) to be imaged.
aberrations: The aberration pattern in the pupil plane.
incident_field: The field from the SLM in the pupil plane.
Returns:
np.ndarray: The resulting image as it would appear on a camera sensor.
"""
# First crop and downscale the source image to have the same size as the output
# todo: add some padding
# todo: add option for oversampling
source_pixel_size = get_pixel_size(source)
target_pixel_size = self.pixel_size / self.magnification
if np.any(source_pixel_size > target_pixel_size):
warnings.warn("The resolution of the specimen image is worse than that of the output.")
# Note: there seems to be a bug (feature?) in `fftconvolve` that shifts the image by one pixel
# when the 'same' option is used. To compensate for this feature,
# the image is shifted by `-source_pixel_size` here.
# TODO: this seems to add an emtpy row and column to the image, which is not what we want.
shift = Quantity((self.xy_stage.y, self.xy_stage.x)) - source_pixel_size
source = place(self.data_shape, target_pixel_size, source, shift)
# Calculate the field in the pupil plane.
#
# First, set up pupil coordinates such that:
# 1. the Fourier transform of the pupil has a resolution
# that exactly matches the resolution of the specimen image.
# 2. the resolution in the pupil plane is high enough such that
# the Fourier transform of the pupil field has a size that is at least equal to the fov of the microscope.
# This means that then number of pixels should be at least as high as the number of points in the fov
# (at the resolution of the specimen image).
#
# The NA of the pupil corresponds to a disk that is contained in this pupil plane.
# We compute the aberrations over the full pupil plane, and clip to the NA by using a disk function.
# TODO: think about what happens when the requested output resolution is lower than the diffraction limit
# at the moment, not the full pupil is used.
# TODO: think about what happens when the slm is smaller than the pupil
# condition 1. Extent of pupil in pupil coordinates: Abbe limit should give pixel_size resolution
pupil_extent = self.wavelength / target_pixel_size / self.numerical_aperture
# condition 2. Minimum number of pixels in x and y should be data_shape
pupil_shape = self.data_shape
# Compute the field in the pupil plane
# The aberrations and the SLM phase pattern are both mapped to the pupil plane coordinates
pupil_field = patterns.disk(pupil_shape, radius=1.0, extent=pupil_extent)
pupil_area = np.sum(pupil_field) # TODO (efficiency): compute area directly from radius
# Add defocus from z-stage
if self.z_stage is not None:
phase = propagation(
pupil_shape,
distance=self.z_stage.position,
wavelength=self.wavelength,
refractive_index=self.immersion_refractive_index,
extent=pupil_extent,
numerical_aperture=self.numerical_aperture,
)
pupil_field = pupil_field * np.exp(1j * phase)
# Project aberrations
if aberrations is not None:
# use default of 2.0 for the extent of the aberration map if no pixel size is provided
aberration_extent = (2.0, 2.0) if get_pixel_size(aberrations) is None else None
pupil_field = pupil_field * np.exp(
1.0j
* project(
aberrations,
source_extent=aberration_extent,
out_extent=pupil_extent,
out_shape=pupil_shape,
transform=self.aberration_transform,
interp=cv2.INTER_CUBIC,
)
)
# Project SLM fields
if incident_field is not None:
pupil_field = pupil_field * project(
incident_field,
out_extent=pupil_extent,
out_shape=pupil_shape,
transform=self.slm_transform,
)
# Compute the point spread function
# This is done by Fourier transforming the pupil field and taking the absolute value squared
# Due to condition 1, after the Fourier transform,
# the pixel size matches that of the source (the specimen image).
# Note: there is no need to `ifftshift` the pupil field, since we are taking the absolute value anyway
psf = np.abs(np.fft.ifft2(pupil_field)) ** 2
psf = np.fft.ifftshift(psf) * (psf.size / pupil_area)
psf = psf**self.nonlinearity # added for 2 pm
self._psf = psf # store psf for later inspection
return fftconvolve(source, psf, "same")
@property
def magnification(self) -> float:
"""Magnification from object plane to image plane.
Note that, as in a real microscope, the magnification does not affect the effective resolution of the image.
The resolution is determined by the Abbe diffraction limit λ/2NA.
Returns:
float: The current magnification factor.
"""
return self._magnification
@magnification.setter
def magnification(self, value: float):
self._magnification = value
@property
def abbe_limit(self) -> Quantity:
"""Returns the Abbe diffraction limit: λ/(2 NA).
This is the theoretical resolution limit of the microscope due to diffraction.
Returns:
Quantity: The Abbe diffraction limit in length units.
"""
return 0.5 * self.wavelength / self.numerical_aperture
@property
def pixel_size(self) -> Quantity:
"""Returns the pixel size in the image plane.
This value is always equal to `source.pixel_size * magnification`.
Returns:
Quantity: The physical size of each pixel in the image plane.
"""
return self._sources[0].pixel_size * self.magnification
@property
def data_shape(self) -> tuple:
"""Returns the shape of the image in the image plane.
Returns:
tuple: The dimensions of the output image (height, width).
"""
return self._data_shape