Exemple #1
0
    def _runWep(self, imgIntraName, imgExtraName, offset, model):

        # Cut the donut image from input files
        centroidFindType = CentroidFindType.Otsu
        imgIntra = Image(centroidFindType=centroidFindType)
        imgExtra = Image(centroidFindType=centroidFindType)

        imgIntraPath = os.path.join(self.testImgDir, imgIntraName)
        imgExtraPath = os.path.join(self.testImgDir, imgExtraName)

        imgIntra.setImg(imageFile=imgIntraPath)

        imgExtra.setImg(imageFile=imgExtraPath)

        xIntra, yIntra, _ = imgIntra.getCenterAndR()
        imgIntraArray = imgIntra.getImg()[int(yIntra) - offset:int(yIntra) +
                                          offset,
                                          int(xIntra) - offset:int(xIntra) +
                                          offset, ]

        xExtra, yExtra, _ = imgExtra.getCenterAndR()
        imgExtraArray = imgExtra.getImg()[int(yExtra) - offset:int(yExtra) +
                                          offset,
                                          int(xExtra) - offset:int(xExtra) +
                                          offset, ]

        # Set the images
        fieldXY = (0, 0)
        imgCompIntra = CompensableImage(centroidFindType=centroidFindType)
        imgCompIntra.setImg(fieldXY, DefocalType.Intra, image=imgIntraArray)

        imgCompExtra = CompensableImage(centroidFindType=centroidFindType)
        imgCompExtra.setImg(fieldXY, DefocalType.Extra, image=imgExtraArray)

        # Calculate the wavefront error

        # Set the instrument
        instDir = os.path.join(getConfigDir(), "cwfs", "instData")
        instAuxTel = Instrument(instDir)
        instAuxTel.config(CamType.AuxTel,
                          imgCompIntra.getImgSizeInPix(),
                          announcedDefocalDisInMm=0.8)

        # Set the algorithm
        algoFolderPath = os.path.join(getConfigDir(), "cwfs", "algo")
        algoAuxTel = Algorithm(algoFolderPath)
        algoAuxTel.config("exp", instAuxTel)
        algoAuxTel.runIt(imgCompIntra, imgCompExtra, model)

        return algoAuxTel.getZer4UpInNm()
Exemple #2
0
    def testGetCenterAndR_ef_withEntropyCheck(self):

        # Creat a zero image
        zeroImg = Image()
        zeroImg.setImg(image=np.ones([4, 4]))

        realcx, realcy, realR, imgBinary = \
            zeroImg.getCenterAndR_ef(checkEntropy=True)

        self.assertEqual(realcx, [])

        # update to the random image
        zeroImg.updateImage(np.random.rand(100, 100))
        realcx, realcy, realR, imgBinary = \
            zeroImg.getCenterAndR_ef(checkEntropy=True)
        self.assertEqual(realcx, [])
Exemple #3
0
    def testZeroImg(self):

        # Creat a zero image
        zeroImg = Image()
        zeroImg.setImg(image=np.zeros([4, 4]))
        self.assertEqual(np.sum(zeroImg.image), 0)

        # Update Image
        zeroImg.updateImage(np.ones([4, 4]))
        self.assertEqual(np.sum(zeroImg.image), 16)

        realcx, realcy, realR, imgBinary = zeroImg.getCenterAndR_ef(
            randNumFilePath=None, checkEntropy=True)

        self.assertEqual(realcx, [])

        # update to the random image
        zeroImg.updateImage(np.random.rand(100, 100))
        realcx, realcy, realR, imgBinary = zeroImg.getCenterAndR_ef(
            randNumFilePath=None, checkEntropy=True)
        self.assertEqual(realcx, [])
Exemple #4
0
    def _calcWfErrAuxTel(self, imgIntraName, imgExtraName, offset, model):

        # Cut the donut image from input files
        centroidFindType = CentroidFindType.Otsu
        imgIntra = Image(centroidFindType=centroidFindType)
        imgExtra = Image(centroidFindType=centroidFindType)

        imgIntraPath = os.path.join(self.testImgDir, imgIntraName)
        imgExtraPath = os.path.join(self.testImgDir, imgExtraName)

        imgIntra.setImg(imageFile=imgIntraPath)
        imgExtra.setImg(imageFile=imgExtraPath)

        xIntra, yIntra = imgIntra.getCenterAndR()[0:2]
        imgIntraArray = imgIntra.getImg()[int(yIntra) - offset:int(yIntra) +
                                          offset,
                                          int(xIntra) - offset:int(xIntra) +
                                          offset, ]

        xExtra, yExtra = imgExtra.getCenterAndR()[0:2]
        imgExtraArray = imgExtra.getImg()[int(yExtra) - offset:int(yExtra) +
                                          offset,
                                          int(xExtra) - offset:int(xExtra) +
                                          offset, ]

        # Calculate the wavefront error
        fieldXY = (0, 0)
        wfErr = self.calcWfErr(
            centroidFindType,
            fieldXY,
            CamType.AuxTel,
            "exp",
            0.8,
            model,
            imageIntra=imgIntraArray,
            imageExtra=imgExtraArray,
        )

        return wfErr
Exemple #5
0
class CompensableImage(object):
    """Instantiate the class of CompensableImage.

    Parameters
    ----------
    centroidFindType : enum 'CentroidFindType', optional
        Algorithm to find the centroid of donut. (the default is
        CentroidFindType.RandomWalk.)
    """
    def __init__(self, centroidFindType=CentroidFindType.RandomWalk):

        self._image = Image(centroidFindType=centroidFindType)
        self.defocalType = DefocalType.Intra

        # Field coordinate in degree
        self.fieldX = 0
        self.fieldY = 0

        # Initial image before doing the compensation
        self.image0 = None

        # Coefficient to do the off-axis correction
        self.offAxisCoeff = np.array([])

        # Defocused offset in files of off-axis correction
        self.offAxisOffset = 0.0

        # True if the image gets the over-compensation
        self.caustic = False

        # Padded mask for use at the offset planes
        self.mask_comp = np.array([], dtype=int)

        # Non-padded mask corresponding to aperture
        self.mask_pupil = np.array([], dtype=int)

    def getDefocalType(self):
        """Get the defocal type.

        Returns
        -------
        enum 'DefocalType'
            Defocal type.
        """

        return self.defocalType

    def getImgObj(self):
        """Get the image object.

        Returns
        -------
        Image
            Image object.
        """

        return self._image

    def getImg(self):
        """Get the image.

        Returns
        -------
        numpy.ndarray
            Image.
        """

        return self._image.getImg()

    def getImgSizeInPix(self):
        """Get the image size in pixel.

        Returns
        -------
        int
            Image size in pixel.
        """

        return self.getImg().shape[0]

    def getOffAxisCoeff(self):
        """Get the coefficients to do the off-axis correction.

        Returns
        -------
        numpy.ndarray
            Coefficients to do the off-axis correction.
        float
            Defocused offset in files of off-axis correction.
        """

        return self.offAxisCoeff, self.offAxisOffset

    def getImgInit(self):
        """Get the initial image before doing the compensation.

        Returns
        -------
        numpy.ndarray
            Initial image before doing the compensation.
        """

        return self.image0

    def isCaustic(self):
        """The image is caustic or not.

        The image might be caustic from the over-compensation.

        Returns
        -------
        bool
            True if the image is caustic.
        """

        return self.caustic

    def getPaddedMask(self):
        """Get the padded mask use at the offset planes.

        Returns
        -------
        numpy.ndarray[int]
            Padded mask.
        """

        return self.mask_comp

    def getNonPaddedMask(self):
        """Get the non-padded mask corresponding to aperture.

        Returns
        -------
        numpy.ndarray[int]
            Non-padded mask
        """

        return self.mask_pupil

    def getFieldXY(self):
        """Get the field x, y in degree.

        Returns
        -------
        float
            Field x in degree.
        float
            Field y in degree.
        """

        return self.fieldX, self.fieldY

    def setImg(self, fieldXY, defocalType, image=None, imageFile=None):
        """Set the wavefront image.

        Parameters
        ----------
        fieldXY : tuple or list
            Position of donut on the focal plane in degree (field x, field y).
        defocalType : enum 'DefocalType'
            Defocal type of image.
        image : numpy.ndarray, optional
            Array of image. (the default is None.)
        imageFile : str, optional
            Path of image file. (the default is None.)
        """

        self._image.setImg(image=image, imageFile=imageFile)
        self._checkImgShape()

        self.fieldX, self.fieldY = fieldXY
        self.defocalType = defocalType

        self._resetInternalAttributes()

    def _checkImgShape(self):
        """Check the image shape.

        Raises
        ------
        RuntimeError
            Only square image stamps are accepted.
        RuntimeError
            Number of pixels cannot be odd numbers.
        """

        img = self.getImg()
        if img.shape[0] != img.shape[1]:
            raise RuntimeError("Only square image stamps are accepted.")
        elif img.shape[0] % 2 == 1:
            raise RuntimeError("Number of pixels cannot be odd numbers.")

    def _resetInternalAttributes(self):
        """Reset the internal attributes."""

        self.image0 = None

        self.offAxisCoeff = np.array([])
        self.offAxisOffset = 0.0

        self.caustic = False

        # Reset all mask related parameters
        self.mask_pupil = np.array([], dtype=int)
        self.mask_comp = np.array([], dtype=int)

    def updateImage(self, image):
        """Update the image of donut.

        Parameters
        ----------
        image : numpy.ndarray
            Donut image.
        """

        self._image.updateImage(image)

    def updateImgInit(self):
        """Update the backup of initial image.

        This will be used in the outer loop iteration, which always uses the
        initial image (image0) before each iteration starts.
        """

        # Update the initial image for future use
        self.image0 = self.getImg().copy()

    def imageCoCenter(self, inst, fov=3.5, debugLevel=0):
        """Shift the weighting center of donut to the center of reference
        image with the correction of projection of fieldX and fieldY.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        fov : float, optional
            Field of view (FOV) of telescope. (the default is 3.5.)
        debugLevel : int, optional
            Show the information under the running. If the value is higher, the
            information shows more. It can be 0, 1, 2, or 3. (the default is
            0.)
        """

        # Calculate the weighting center (x, y) and radius
        x1, y1 = self._image.getCenterAndR()[0:2]

        # Show the co-center information
        if debugLevel >= 3:
            print("imageCoCenter: (x, y) = (%8.2f,%8.2f)\n" % (x1, y1))

        # Calculate the center position on image
        # 0.5 is the half of 1 pixel
        dimOfDonut = inst.getDimOfDonutOnSensor()
        stampCenterx1 = dimOfDonut / 2 + 0.5
        stampCentery1 = dimOfDonut / 2 + 0.5

        # Shift in the radial direction
        # The field of view (FOV) of LSST camera is 3.5 degree
        offset = inst.getDefocalDisOffset()
        pixelSize = inst.getCamPixelSize()
        radialShift = fov * (offset / 1e-3) * (10e-6 / pixelSize)

        # Calculate the projection of distance of donut to center
        fieldDist = self._getFieldDistFromOrigin()
        radialShift = radialShift * (fieldDist / (fov / 2))

        # Do not consider the condition out of FOV of lsst
        if fieldDist > (fov / 2):
            radialShift = 0

        # Calculate the cos(theta) for projection
        I1c = self.fieldX / fieldDist

        # Calculate the sin(theta) for projection
        I1s = self.fieldY / fieldDist

        # Get the projected x, y-coordinate
        stampCenterx1 = stampCenterx1 + radialShift * I1c
        stampCentery1 = stampCentery1 + radialShift * I1s

        # Shift the image to the projected position
        self.updateImage(
            np.roll(self.getImg(), int(np.round(stampCentery1 - y1)), axis=0))
        self.updateImage(
            np.roll(self.getImg(), int(np.round(stampCenterx1 - x1)), axis=1))

    def _getFieldDistFromOrigin(self, fieldX=None, fieldY=None, minDist=1e-8):
        """Get the field distance from the origin.

        Parameters
        ----------
        fieldX : float, optional
            Field x in degree. If the input is None, the value of self.fieldX
            will be used. (the default is None.)
        fieldY : float, optional
            Field y in degree. If the input is None, the value of self.fieldY
            will be used. (the default is None.)
        minDist : float, optional
            Minimum distace. In some cases, the field distance will be the
            denominator in other functions. (the default is 1e-8.)

        Returns
        -------
        float
            Field distance from the origin.
        """

        if fieldX is None:
            fieldX = self.fieldX

        if fieldY is None:
            fieldY = self.fieldY

        fieldDist = np.hypot(fieldX, fieldY)
        if fieldDist == 0:
            fieldDist = minDist

        return fieldDist

    def compensate(self, inst, algo, zcCol, model):
        """Calculate the image compensated from the affection of wavefront.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        algo : Algorithm
            Algorithm to solve the Poisson's equation. It can by done by the
            fast Fourier transform or serial expansion.
        zcCol : numpy.ndarray
            Coefficients of wavefront.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Raises
        ------
        RuntimeError
            input:size zcCol in compensate needs to be a numTerms row column
            vector.
        """

        # Check the condition of inputs
        numTerms = algo.getNumOfZernikes()
        if (zcCol.ndim == 1) and (len(zcCol) != numTerms):
            raise RuntimeError(
                "input:size",
                "zcCol in compensate needs to be a %d row column vector. \n" %
                numTerms,
            )

        # Dimension of image
        sm, sn = self.getImg().shape

        # Dimension of projected image on focal plane
        projSamples = sm

        # Let us create a look-up table for x -> xp first.
        luty, lutx = np.mgrid[-(projSamples / 2 - 0.5):(projSamples / 2 + 0.5),
                              -(projSamples / 2 - 0.5):(projSamples / 2 +
                                                        0.5), ]

        sensorFactor = inst.getSensorFactor()
        lutx = lutx / (projSamples / 2 / sensorFactor)
        luty = luty / (projSamples / 2 / sensorFactor)

        # Set up the mapping
        lutxp, lutyp, J = self._aperture2image(inst, algo, zcCol, lutx, luty,
                                               projSamples, model)

        show_lutxyp = self._showProjection(lutxp,
                                           lutyp,
                                           sensorFactor,
                                           projSamples,
                                           raytrace=False)
        if np.all(show_lutxyp <= 0):
            self.caustic = True
            return

        # Extend the dimension of image by 20 pixel in x and y direction
        show_lutxyp = padArray(show_lutxyp, projSamples + 20)

        # Get the binary matrix of image on pupil plane if raytrace=False
        struct0 = generate_binary_structure(2, 1)
        struct = iterate_structure(struct0, 4)
        struct = binary_dilation(struct, structure=struct0,
                                 iterations=2).astype(int)
        show_lutxyp = binary_dilation(show_lutxyp, structure=struct)
        show_lutxyp = binary_erosion(show_lutxyp, structure=struct)

        # Extract the region from the center of image and get the original one
        show_lutxyp = extractArray(show_lutxyp, projSamples)

        # Recenter the image
        imgRecenter = self.centerOnProjection(self.getImg(),
                                              show_lutxyp.astype(float),
                                              window=20)
        self.updateImage(imgRecenter)

        # Construct the interpolant to get the intensity on (x', p') plane
        # that corresponds to the grid points on (x,y)
        yp, xp = np.mgrid[-(sm / 2 - 0.5):(sm / 2 + 0.5),
                          -(sm / 2 - 0.5):(sm / 2 + 0.5)]

        xp = xp / (sm / 2 / sensorFactor)
        yp = yp / (sm / 2 / sensorFactor)

        # Put the NaN to be 0 for the interpolate to use
        lutxp[np.isnan(lutxp)] = 0
        lutyp[np.isnan(lutyp)] = 0

        # Construct the function for interpolation
        ip = RectBivariateSpline(yp[:, 0], xp[0, :], self.getImg(), kx=1, ky=1)

        # Construct the projected image by the interpolation
        lutIp = ip(lutyp, lutxp, grid=False)

        # Calculate the image on focal plane with compensation based on flux
        # conservation
        # I(x, y)/I'(x', y') = J = (dx'/dx)*(dy'/dy) - (dx'/dy)*(dy'/dx)
        self.updateImage(lutIp * J)

        if self.defocalType == DefocalType.Extra:
            self.updateImage(np.rot90(self.getImg(), k=2))

        # Put NaN to be 0
        imgCompensate = self.getImg()
        imgCompensate[np.isnan(imgCompensate)] = 0

        # Check the compensated image has the problem or not.
        # The negative value means the over-compensation from wavefront error
        if np.any(imgCompensate < 0) and np.all(self.image0 >= 0):
            print(
                "WARNING: negative scale parameter, image is within caustic, zcCol (in um)=\n"
            )
            self.caustic = True

        # Put the overcompensated part to be 0
        imgCompensate[imgCompensate < 0] = 0
        self.updateImage(imgCompensate)

    def _aperture2image(self, inst, algo, zcCol, lutx, luty, projSamples,
                        model):
        """Calculate the x, y-coordinate on the focal plane and the related
        Jacobian matrix.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        algo : Algorithm
            Algorithm to solve the Poisson's equation. It can by done by the
            fast Fourier transform or serial expansion.
        zcCol : numpy.ndarray
            Coefficients of optical basis. It is Zernike polynomials in the
            baseline.
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        projSamples : int
            Dimension of projected image. This value considers the
            magnification ratio of donut image.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Returns
        -------
        numpy.ndarray
            X coordinate on the focal plane.
        numpy.ndarray
            Y coordinate on the focal plane.
        numpy.ndarray
            Jacobian matrix between the pupil and focal plane.
        """

        # Get the radius: R = D/2
        R = inst.getApertureDiameter() / 2

        # Calculate C = -f(f-l)/l/R^2. This is for the calculation of reduced
        # coordinate.
        defocalDisOffset = inst.getDefocalDisOffset()
        if self.defocalType == DefocalType.Intra:
            l = defocalDisOffset
        elif self.defocalType == DefocalType.Extra:
            l = -defocalDisOffset

        focalLength = inst.getFocalLength()
        myC = -focalLength * (focalLength - l) / l / R**2

        # Get the functions to do the off-axis correction by numerical fitting
        # Order to do the off-axis correction. The order is 10 now.
        offAxisPolyOrder = algo.getOffAxisPolyOrder()
        polyFunc = self._getFunction("poly%d_2D" % offAxisPolyOrder)
        polyGradFunc = self._getFunction("poly%dGrad" % offAxisPolyOrder)

        # Calculate the distance to center
        lutr = np.sqrt(lutx**2 + luty**2)

        # Calculated the extended ring radius (delta r), which is to extended
        # the available pupil area.
        # 1 pixel larger than projected pupil. No need to be EF-like, anything
        # outside of this will be masked off by the computational mask
        sensorFactor = inst.getSensorFactor()
        onepixel = 1 / (projSamples / 2 / sensorFactor)

        # Get the index that the point is out of the range of extended pupil
        obscuration = inst.getObscuration()
        idxout = (lutr > 1 + onepixel) | (lutr < obscuration - onepixel)

        # Define the element to be NaN if it is out of range
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the index in the extended area of outer boundary with the width
        # of onepixel
        idxbound = (lutr <= 1 + onepixel) & (lutr > 1)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=1)
        lutx[idxbound] = lutx[idxbound] / lutr[idxbound]
        luty[idxbound] = luty[idxbound] / lutr[idxbound]

        # Get the index in the extended area of inner boundary with the width
        # of onepixel
        idxinbd = (lutr < obscuration) & (lutr > obscuration - onepixel)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=obscuration)
        lutx[idxinbd] = lutx[idxinbd] / lutr[idxinbd] * obscuration
        luty[idxinbd] = luty[idxinbd] / lutr[idxinbd] * obscuration

        # Get the corrected x, y-coordinate on focal plane (lutxp, lutyp)
        if model == "paraxial":
            # No correction is needed in "paraxial" model
            lutxp = lutx
            lutyp = luty

        elif model == "onAxis":

            # Calculate F(x, y) = m * sqrt(f^2-R^2) / sqrt(f^2-(x^2+y^2)*R^2)
            # m is the mask scaling factor
            myA2 = (focalLength**2 - R**2) / (focalLength**2 - lutr**2 * R**2)

            # Put the unphysical value as NaN
            myA = myA2.copy()
            idx = myA < 0
            myA[idx] = np.nan
            myA[~idx] = np.sqrt(myA2[~idx])

            # Mask scaling factor (for fast beam)
            maskScalingFactor = algo.getMaskScalingFactor()

            # Calculate the x, y-coordinate on focal plane
            # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)
            lutxp = maskScalingFactor * myA * lutx
            lutyp = maskScalingFactor * myA * luty

        elif model == "offAxis":

            # Get the coefficient of polynomials for off-axis correction
            tt = self.offAxisOffset

            cx = (self.offAxisCoeff[0, :] - self.offAxisCoeff[2, :]) * (
                tt + l) / (2 * tt) + self.offAxisCoeff[2, :]
            cy = (self.offAxisCoeff[1, :] - self.offAxisCoeff[3, :]) * (
                tt + l) / (2 * tt) + self.offAxisCoeff[3, :]

            # This will be inverted back by typesign later on.
            # We do the inversion here to make the (x,y)->(x',y') equations has
            # the same form as the paraxial case.
            cx = np.sign(l) * cx
            cy = np.sign(l) * cy

            # Do the orthogonalization: x'=1/sqrt(2)*(x+y), y'=1/sqrt(2)*(x-y)
            # Calculate the rotation angle for the orthogonalization
            fieldDist = self._getFieldDistFromOrigin()
            costheta = (self.fieldX + self.fieldY) / fieldDist / np.sqrt(2)
            if costheta > 1:
                costheta = 1
            elif costheta < -1:
                costheta = -1

            sintheta = np.sqrt(1 - costheta**2)
            if self.fieldY < self.fieldX:
                sintheta = -sintheta

            # Create the pupil grid in off-axis model. This gives the
            # x,y-coordinate in the extended ring area defined by the parameter
            # of onepixel.

            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self._interpMaskParam(
                self.fieldX, self.fieldY, inst.getMaskOffAxisCorr())

            lutx, luty = self._createPupilGrid(
                lutx,
                luty,
                onepixel,
                maskCa,
                maskCb,
                maskRa,
                maskRb,
                self.fieldX,
                self.fieldY,
            )

            # Calculate the x, y-coordinate on focal plane

            # First rotate back to reference orientation
            lutx0 = lutx * costheta + luty * sintheta
            luty0 = -lutx * sintheta + luty * costheta

            # Use the mapping at reference orientation
            lutxp0 = polyFunc(cx, lutx0, y=luty0)
            lutyp0 = polyFunc(cy, lutx0, y=luty0)

            # Rotate back to focal plane
            lutxp = lutxp0 * costheta - lutyp0 * sintheta
            lutyp = lutxp0 * sintheta + lutyp0 * costheta

            # Zemax data are in mm, therefore 1000
            dimOfDonut = inst.getDimOfDonutOnSensor()
            pixelSize = inst.getCamPixelSize()
            reduced_coordi_factor = 1e-3 / (dimOfDonut / 2 * pixelSize /
                                            sensorFactor)

            # Reduced coordinates, so that this can be added with the dW/dz
            lutxp = lutxp * reduced_coordi_factor
            lutyp = lutyp * reduced_coordi_factor

        else:
            print("Wrong optical model type in compensate. \n")
            return

        # Obscuration of annular aperture
        zobsR = algo.getObsOfZernikes()

        # Calculate the x, y-coordinate on focal plane
        # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)

        # In Model basis (zer: Zernike polynomials)
        if zcCol.ndim == 1:
            lutxp = lutxp + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                     "dx")
            lutyp = lutyp + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                     "dy")

        # Make the sign to be consistent
        if self.defocalType == DefocalType.Extra:
            lutxp = -lutxp
            lutyp = -lutyp

        # Calculate the Jacobian matrix
        # In Model basis (zer: Zernike polynomials)
        if zcCol.ndim == 1:
            if model == "paraxial":
                J = (1 + myC *
                     ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "1st") +
                     myC**2 *
                     ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "2nd"))

            elif model == "onAxis":
                xpox = maskScalingFactor * myA * (
                    1 + lutx**2 * R**2.0 / (focalLength**2 - R**2 * lutr**2)
                ) + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx2")

                ypoy = maskScalingFactor * myA * (
                    1 + luty**2 * R**2.0 / (focalLength**2 - R**2 * lutr**2)
                ) + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy2")

                xpoy = maskScalingFactor * myA * lutx * luty * R**2 / (
                    focalLength**2 - R**2 * lutr**2
                ) + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dxy")

                ypox = xpoy

                J = xpox * ypoy - xpoy * ypox

            elif model == "offAxis":
                xp0ox = (polyGradFunc(cx, lutx0, luty0, "dx") * costheta -
                         polyGradFunc(cx, lutx0, luty0, "dy") * sintheta)

                yp0ox = (polyGradFunc(cy, lutx0, luty0, "dx") * costheta -
                         polyGradFunc(cy, lutx0, luty0, "dy") * sintheta)

                xp0oy = (polyGradFunc(cx, lutx0, luty0, "dx") * sintheta +
                         polyGradFunc(cx, lutx0, luty0, "dy") * costheta)

                yp0oy = (polyGradFunc(cy, lutx0, luty0, "dx") * sintheta +
                         polyGradFunc(cy, lutx0, luty0, "dy") * costheta)

                xpox = (xp0ox * costheta - yp0ox * sintheta
                        ) * reduced_coordi_factor + myC * ZernikeAnnularGrad(
                            zcCol, lutx, luty, zobsR, "dx2")

                ypoy = (xp0oy * sintheta + yp0oy * costheta
                        ) * reduced_coordi_factor + myC * ZernikeAnnularGrad(
                            zcCol, lutx, luty, zobsR, "dy2")

                temp = myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                "dxy")

                # if temp==0,xpoy doesn't need to be symmetric about x=y
                xpoy = (xp0oy * costheta -
                        yp0oy * sintheta) * reduced_coordi_factor + temp

                # xpoy-flipud(rot90(ypox))==0 is true
                ypox = (xp0ox * sintheta +
                        yp0ox * costheta) * reduced_coordi_factor + temp

                J = xpox * ypoy - xpoy * ypox

        return lutxp, lutyp, J

    def _getFunction(self, name):
        """Decide to call the function of _poly10_2D() or _poly10Grad().

        This is to correct the off-axis distortion. A numerical solution with
        2-dimensions 10 order polynomials to map between the telescope
        aperture and defocused image plane is used.

        Parameters
        ----------
        name : str
            Function name to call.

        Returns
        -------
        numpy.ndarray
            Corrected image after the correction.

        Raises
        ------
        RuntimeError
            Raise error if the function name does not exist.
        """

        # Construct the dictionary table for calling function.
        # The reason to use the dictionary is for the future's extension.
        funcTable = dict(poly10_2D=self._poly10_2D,
                         poly10Grad=self._poly10Grad)

        # Look for the function to call
        if name in funcTable:
            return funcTable[name]

        # Error for unknown function name
        raise RuntimeError("Unknown function name: %s" % name)

    def _poly10_2D(self, c, data, y=None):
        """Correct the off-axis distortion by fitting with a 10 order
        polynomial equation.

        Parameters
        ----------
        c : numpy.ndarray
            Parameters of off-axis distrotion.
        data : numpy.ndarray
            X, y-coordinate on aperture. If y is provided this will be just
            the x-coordinate.
        y : numpy.ndarray, optional
            Y-coordinate at aperture. (the default is None.)

        Returns
        -------
        numpy.ndarray
            Corrected parameters for off-axis distortion.
        """

        # Decide the x, y-coordinate data on aperture
        if y is None:
            x = data[0, :]
            y = data[1, :]
        else:
            x = data

        # Correct the off-axis distortion

        # Need to reorder the coefficients for use with GalSim:
        carr = np.zeros((11, 11))
        carr[np.tril_indices(11)] = c
        for i in range(0, 11):
            carr[:, i] = np.roll(carr[:, i], -i)
        return horner2d(x, y, carr)

    def _poly10Grad(self, c, x, y, atype):
        """Correct the off-axis distortion by fitting with a 10 order
        polynomial equation in the gradient part.

        Parameters
        ----------
        c : numpy.ndarray
            Parameters of off-axis distrotion.
        x : numpy.ndarray
            X-coordinate at aperture.
        y : numpy.ndarray
            Y-coordinate at aperture.
        atype : str
            Direction of gradient. It can be "dx" or "dy".

        Returns
        -------
        numpy.ndarray
            Corrected parameters for off-axis distortion.

        Raises
        ------
        ValueError
            If atype is not 'dx' or 'dy'.
        """
        if atype not in ["dx", "dy"]:
            raise ValueError(f"Unknown atype: {atype}")

        carr = np.zeros((11, 11))
        carr[np.tril_indices(11)] = c
        for i in range(0, 11):
            carr[:, i] = np.roll(carr[:, i], -i)
        grad = np.zeros(carr.shape, dtype=np.float64)

        if atype == "dx":
            for (i, j) in zip(*np.nonzero(carr)):
                if i > 0:
                    grad[i - 1, j] = carr[i, j] * i
        elif atype == "dy":
            for (i, j) in zip(*np.nonzero(carr)):
                if j > 0:
                    grad[i, j - 1] = carr[i, j] * j
        return horner2d(x, y, grad)

    def _createPupilGrid(self, lutx, luty, onepixel, ca, cb, ra, rb, fieldX,
                         fieldY):
        """Create the pupil grid in off-axis model.

        This function gives the x,y-coordinate in the extended ring area
        defined by the parameter of onepixel.

        Parameters
        ----------
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        onepixel : float
            Extended delta radius.
        ca : float
            Center of outer ring on the pupil plane.
        cb : float
            Center of inner ring on the pupil plane.
        ra : float
            Radius of outer ring on the pupil plane.
        rb : float
            Radius of inner ring on the pupil plane.
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.

        Returns
        -------
        numpy.ndarray
            X-coordinate of extended ring area on pupil plane.
        numpy.ndarray
            Y-coordinate of extended ring area on pupil plane.
        """

        # Rotate the mask center after the off-axis correction based on the
        # position of fieldX and fieldY
        cax, cay, cbx, cby = self._rotateMaskParam(ca, cb, fieldX, fieldY)

        # Get x, y coordinate of extended outer boundary by the linear
        # approximation
        lutx, luty = self._approximateExtendedXY(lutx, luty, cax, cay, ra,
                                                 ra + onepixel, "outer")

        # Get x, y coordinate of extended inner boundary by the linear
        # approximation
        lutx, luty = self._approximateExtendedXY(lutx, luty, cbx, cby,
                                                 rb - onepixel, rb, "inner")

        return lutx, luty

    def _approximateExtendedXY(self, lutx, luty, cenX, cenY, innerR, outerR,
                               config):
        """Calculate the x, y-coordinate on pupil plane in the extended ring
        area by the linear approximation, which is used in the off-axis
        correction.

        Parameters
        ----------
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        cenX : float
            X-coordinate of boundary ring center.
        cenY : float
            Y-coordinate of boundary ring center.
        innerR : float
            Inner radius of extended ring.
        outerR : float
            Outer radius of extended ring.
        config : str
            Configuration to calculate the x,y-coordinate in the extended ring.
            "inner": inner extended ring; "outer": outer extended ring.

        Returns
        -------
        numpy.ndarray
            X-coordinate of extended ring area on pupil plane.
        numpy.ndarray
            Y-coordinate of extended ring area on pupil plane.
        """

        # Calculate the distance to rotated center of boundary ring
        lutr = np.sqrt((lutx - cenX)**2 + (luty - cenY)**2)

        # Define NaN to be 999 for the comparison in the following step
        tmp = lutr.copy()
        tmp[np.isnan(tmp)] = 999

        # Get the available index that the related distance is between innderR
        # and outerR
        idxbound = (~np.isnan(lutr)) & (tmp >= innerR) & (tmp <= outerR)

        # Deside R based on the configuration
        if config == "outer":
            R = innerR
            # Get the index that the related distance is bigger than outerR
            idxout = tmp > outerR
        elif config == "inner":
            R = outerR
            # Get the index that the related distance is smaller than innerR
            idxout = tmp < innerR

        # Put the x, y-coordinate to be NaN if it is inside/ outside the pupil
        # that is after the off-axis correction.
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the x, y-coordinate in this ring area by the linear approximation
        lutx[idxbound] = (lutx[idxbound] - cenX) / lutr[idxbound] * R + cenX
        luty[idxbound] = (luty[idxbound] - cenY) / lutr[idxbound] * R + cenY

        return lutx, luty

    def _rotateMaskParam(self, ca, cb, fieldX, fieldY):
        """Rotate the mask-related parameters of center.

        Parameters
        ----------
        ca : float
            Mask-related parameter of center.
        cb : float
            Mask-related parameter of center.
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.

        Returns
        -------
        float
            Projected x element after the rotation.
        float
            Projected y element after the rotation.
        float
            Projected x element after the rotation.
        float
            Projected y element after the rotation.
        """

        # Calculate the sin(theta) and cos(theta) for the rotation
        fieldDist = self._getFieldDistFromOrigin(fieldX=fieldX,
                                                 fieldY=fieldY,
                                                 minDist=0)
        if fieldDist == 0:
            c = 0
            s = 0
        else:
            # Calculate cos(theta)
            c = fieldX / fieldDist

            # Calculate sin(theta)
            s = fieldY / fieldDist

        # Projected x and y coordinate after the rotation
        cax = c * ca
        cay = s * ca

        cbx = c * cb
        cby = s * cb

        return cax, cay, cbx, cby

    def centerOnProjection(self, img, template, window=20):
        """Center the image to the template's center.

        Parameters
        ----------
        img : numpy.array
            Image to be centered with the template. The input image needs to
            be a n-by-n matrix.
        template : numpy.array
            Template image to have the same dimension as the input image
            ('img'). The center of template is the position of input image
            tries to align with.
        window : int, optional
            Size of window in pixel. Assume the difference of centers of input
            image and template is in this range (e.g. [-window/2, window/2] if
            1D). (the default is 20.)

        Returns
        -------
        numpy.array
            Recentered image.
        """

        # Calculate the cross-correlate
        corr = correlate(img, template, mode="same")

        # Calculate the shifts of center

        # Only consider the shifts in a certain window (range)
        # Align the input image to the center of template
        length = template.shape[0]
        center = length // 2

        r = window // 2

        mask = np.zeros(corr.shape)
        mask[center - r:center + r, center - r:center + r] = 1
        idx = np.argmax(corr * mask)

        # The above 'idx' is an interger. Need to rematch it to the
        # two-dimension position (x and y)
        xmatch = idx % length
        ymatch = idx // length

        dx = center - xmatch
        dy = center - ymatch

        # Shift/ recenter the input image
        return np.roll(np.roll(img, dx, axis=1), dy, axis=0)

    def setOffAxisCorr(self, inst, order):
        """Set the coefficients of off-axis correction for x, y-projection of
        intra- and extra-image.

        This is for the mapping of coordinate from the telescope aperture to
        defocal image plane.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        order : int
            Up to order-th of off-axis correction.
        """

        # List of configuration
        configList = ["cxin", "cyin", "cxex", "cyex"]

        # Get all files in the directory
        instDir = inst.getInstFileDir()
        fileList = [
            f for f in os.listdir(instDir)
            if os.path.isfile(os.path.join(instDir, f))
        ]

        # Read files
        offAxisCoeff = []
        for config in configList:

            # Construct the configuration file name
            for fileName in fileList:
                m = re.match(r"\S*%s\S*.yaml" % config, fileName)
                if m is not None:
                    matchFileName = m.group()
                    break

            filePath = os.path.join(instDir, matchFileName)
            corrCoeff, offset = self._getOffAxisCorrSingle(filePath)
            offAxisCoeff.append(corrCoeff)

        # Give the values
        self.offAxisCoeff = np.array(offAxisCoeff)
        self.offAxisOffset = offset

    def _getOffAxisCorrSingle(self, confFile):
        """Get the image-related parameters for the off-axis distortion by the
        linear approximation with a series of fitted parameters with LSST
        ZEMAX model.

        Parameters
        ----------
        confFile : str
            Path of configuration file.

        Returns
        -------
        numpy.ndarray
            Coefficients for the off-axis distortion based on the linear
            response.
        float
            Defocal distance in m.
        """

        fieldDist = self._getFieldDistFromOrigin(minDist=0.0)

        # Read the configuration file
        paramReader = ParamReader()
        paramReader.setFilePath(confFile)
        cdata = paramReader.getMatContent()

        # Record the offset (defocal distance)
        offset = cdata[0, 0]

        # Take the reference parameters
        c = cdata[:, 1:]

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(c[:, 0]**2 + c[:, 1]**2)

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        corr_coeff = self._linearApprox(fieldDist, ruler, c[:, 2:])

        return corr_coeff, offset

    def _interpMaskParam(self, fieldX, fieldY, maskParam):
        """Get the mask-related parameters for the off-axis distortion and
        vignetting correction by the linear approximation with a series of
        fitted parameters with LSST ZEMAX model.

        Parameters
        ----------
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.
        maskParam : numpy.ndarray
            Fitted coefficients for the off-axis distortion and vignetting
            correction.

        Returns
        -------
        float
            'ca' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'ra' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'cb' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'rb' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        """

        # Calculate the distance from donut to origin (aperture)
        filedDist = np.sqrt(fieldX**2 + fieldY**2)

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(2) * maskParam[:, 0]

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        param = self._linearApprox(filedDist, ruler, maskParam[:, 1:])

        # Define related parameters
        ca = param[0]
        ra = param[1]
        cb = param[2]
        rb = param[3]

        return ca, ra, cb, rb

    def _linearApprox(self, fieldDist, ruler, parameters):
        """Get the fitted parameters for off-axis correction by linear
        approximation.

        Parameters
        ----------
        fieldDist : float
            Field distance from donut to origin (aperture).
        ruler : numpy.ndarray
            A series of distance with available parameters for the fitting.
        parameters : numpy.ndarray
            Referenced parameters for the fitting.

        Returns
        -------
        numpy.ndarray
            Fitted parameters based on the linear approximation.
        """

        # Sort the ruler and parameters based on the magnitude of ruler
        sortIndex = np.argsort(ruler)
        ruler = ruler[sortIndex]
        parameters = parameters[sortIndex, :]

        # Compare the distance to center (aperture) between donut and standard
        compDis = ruler >= fieldDist

        # fieldDist is too big and out of range
        if fieldDist > ruler.max():
            # Take the coefficients in the highest boundary
            p2 = parameters.shape[0] - 1
            p1 = 0
            w1 = 0
            w2 = 1

        # fieldDist is too small to be in the range
        elif fieldDist < ruler.min():
            # Take the coefficients in the lowest boundary
            p2 = 0
            p1 = 0
            w1 = 1
            w2 = 0

        # fieldDist is in the range
        else:
            # Find the boundary of fieldDist in the known data
            p2 = compDis.argmax()
            p1 = p2 - 1

            # Calculate the weighting ratio
            w1 = (ruler[p2] - fieldDist) / (ruler[p2] - ruler[p1])
            w2 = 1 - w1

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        param = w1 * parameters[p1, :] + w2 * parameters[p2, :]

        return param

    def makeMaskList(self, inst, model):
        """Calculate the mask list based on the obscuration and optical model.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Returns
        -------
        numpy.ndarray
            The list of mask.
        """

        # Masklist = [center_x, center_y, radius_of_boundary,
        #             1/ 0 for outer/ inner boundary]
        obscuration = inst.getObscuration()
        if model in ("paraxial", "onAxis"):

            if obscuration == 0:
                masklist = np.array([[0, 0, 1, 1]])
            else:
                masklist = np.array([[0, 0, 1, 1], [0, 0, obscuration, 0]])
        else:
            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self._interpMaskParam(
                self.fieldX, self.fieldY, inst.getMaskOffAxisCorr())

            # Rotate the mask-related parameters of center
            cax, cay, cbx, cby = self._rotateMaskParam(maskCa, maskCb,
                                                       self.fieldX,
                                                       self.fieldY)
            masklist = np.array([
                [0, 0, 1, 1],
                [0, 0, obscuration, 0],
                [cax, cay, maskRa, 1],
                [cbx, cby, maskRb, 0],
            ])

        return masklist

    def _showProjection(self,
                        lutxp,
                        lutyp,
                        sensorFactor,
                        projSamples,
                        raytrace=False):
        """Calculate the x, y-projection of image on pupil.

        This can be used to calculate the center of projection in compensate().

        Parameters
        ----------
        lutxp : numpy.ndarray
            X-coordinate on pupil plane. The value of element will be NaN if
            that point is not inside the pupil.
        lutyp : numpy.ndarray
            Y-coordinate on pupil plane. The value of element will be NaN if
            that point is not inside the pupil.
        sensorFactor : float
            Sensor factor.
        projSamples : int
            Dimension of projected image. This value considers the
            magnification ratio of donut image.
        raytrace : bool, optional
            Consider the ray trace or not. If the value is true, the times of
            photon hit will aggregate. (the default is False.)

        Returns
        -------
        numpy.ndarray
            Projection of image. It will be a binary image if raytrace=False.
        """

        # Dimension of pupil image
        n1, n2 = lutxp.shape

        # Construct the binary matrix on pupil. It is noted that if the
        # raytrace is true, the value of element is allowed to be greater
        # than 1.
        show_lutxyp = np.zeros((n1, n2))

        # Get the index in pupil. If a point's value is NaN, this point is
        # outside the pupil.
        idx = ~np.isnan(lutxp)

        # Calculate the projected x, y-coordinate in pixel
        # x=0.5 is center of pixel#1
        xR = np.zeros((n1, n2))
        yR = np.zeros((n1, n2))

        xR[idx] = np.round((lutxp[idx] + sensorFactor) *
                           (projSamples / sensorFactor) / 2 + 0.5)
        yR[idx] = np.round((lutyp[idx] + sensorFactor) *
                           (projSamples / sensorFactor) / 2 + 0.5)

        # Check the projected coordinate is in the range of image or not.
        # If the check passes, the times will be recorded.
        mask = np.bitwise_and(
            np.bitwise_and(np.bitwise_and(xR > 0, xR < n2), yR > 0), yR < n1)

        # Check the projected coordinate is in the range of image or not.
        # If the check passes, the times will be recorded.
        if raytrace:
            for ii, jj in zip(
                    np.array(yR - 1, dtype=int)[mask],
                    np.array(xR - 1, dtype=int)[mask]):
                show_lutxyp[ii, jj] += 1
        else:
            show_lutxyp[np.array(yR - 1, dtype=int)[mask],
                        np.array(xR - 1, dtype=int)[mask]] = 1

        return show_lutxyp

    def makeMask(self, inst, model, boundaryT, maskScalingFactorLocal):
        """Get the binary mask which considers the obscuration and off-axis
        correction.

        There will be two mask parameters to be calculated:
        mask_comp: computation mask, i.e. padded mask,
            for use at the offset planes
        mask_pupil: pupil mask, i.e. non-padded mask,
            corresponding to aperture

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".
        boundaryT : int
            Extended boundary in pixel. It defines how far the computation mask
            extends beyond the pupil mask. And, in fft, it is also the width of
            Neuman boundary where the derivative of the wavefront is set to
            zero.
        maskScalingFactorLocal : float
            Mask scaling factor (for fast beam) for local correction.
        """

        dimOfDonut = inst.getDimOfDonutOnSensor()
        self.mask_pupil = np.ones(dimOfDonut, dtype=int)
        self.mask_comp = self.mask_pupil.copy()

        apertureDiameter = inst.getApertureDiameter()
        focalLength = inst.getFocalLength()
        offset = inst.getDefocalDisOffset()
        rMask = apertureDiameter / (2 * focalLength /
                                    offset) * maskScalingFactorLocal

        # Get the mask list
        pixelSize = inst.getCamPixelSize()
        xSensor, ySensor = inst.getSensorCoor()
        masklist = self.makeMaskList(inst, model)
        for ii in range(masklist.shape[0]):

            # Distance to center on pupil
            r = np.sqrt((xSensor - masklist[ii, 0])**2 +
                        (ySensor - masklist[ii, 1])**2)

            # Find the indices that correspond to the mask element, set them to
            # the pass/ block boolean

            # Get the index inside the aperture
            idx = r <= masklist[ii, 2]

            # Get the higher and lower boundary beyond the pupil mask by
            # extension.
            # The extension level is dicided by boundaryT.
            # In fft, this is also the Neuman boundary where the derivative of
            # the wavefront is set to zero.
            if masklist[ii, 3] >= 1:
                aidx = np.nonzero(r <= masklist[ii, 2] *
                                  (1 + boundaryT * pixelSize / rMask))
            else:
                aidx = np.nonzero(r <= masklist[ii, 2] *
                                  (1 - boundaryT * pixelSize / rMask))

            # Initialize both mask elements to the opposite of the pass/ block
            # boolean
            mask_pupil_ii = (1 - int(masklist[ii, 3])) * np.ones(
                [dimOfDonut, dimOfDonut], dtype=int)
            mask_comp_ii = mask_pupil_ii.copy()

            mask_pupil_ii[idx] = int(masklist[ii, 3])
            mask_comp_ii[aidx] = int(masklist[ii, 3])

            # Multiplicatively add the current mask elements to the model
            # masks.
            # This is try to find the common mask region.

            # non-padded mask corresponding to aperture
            self.mask_pupil = self.mask_pupil * mask_pupil_ii

            # padded mask for use at the offset planes
            self.mask_comp = self.mask_comp * mask_comp_ii
class CompensationImageDecorator(object):

    # Constant
    INTRA = "intra"
    EXTRA = "extra"

    def __init__(self):
        """
        
        Instantiate the class of CompensationImageDecorator.
        """

        self.__image = None

        # Parameters used for transport of intensity equation (TIE)
        self.sizeinPix = None
        self.fieldX = None
        self.fieldY = None
        self.image0 = None
        self.fldr = None
        self.atype = None
        self.offAxis_coeff = None
        self.offAxisOffset = None
        self.caustic = False

        self.pMask = None
        self.cMask = None

    def __getattr__(self, attributeName):
        """
        
        Use the functions and attributes hold by the object.
        
        Arguments:
            attributeName {[str]} -- Name of attribute or function.
        
        Returns:
            [str] -- Returned values.
        """
        return getattr(self.__image, attributeName)

    def setImg(self, fieldXY, image=None, imageFile=None, atype=None):
        """
        
        Set the wavefront image.
        
        Arguments:
            fieldXY {[float]} -- Position of donut on the focal plane in degree.
        
        Keyword Arguments:
            image {[float]} -- Array of image. (default: {None})
            imageFile {[string]} -- Path of image file. (default: {None})
            atype {[string]} -- Type of image. It should be "intra" or "extra". (default: {None})
        
        Raises:
            TypeError -- Error if the atype is not "intra" or "extra". 
        """

        # Instantiate the image object
        self.__image = Image()

        # Read the file if there is no input image
        self.__image.setImg(image=image, imageFile=imageFile)

        # Make sure the image size is n by n
        if (self.__image.image.shape[0] != self.__image.image.shape[1]):
            raise RuntimeError("Only square image stamps are accepted.")
        elif (self.__image.image.shape[0] % 2 == 1):
            raise RuntimeError("Number of pixels cannot be odd numbers.")

        # Dimension of image
        self.sizeinPix = self.__image.image.shape[0]

        # Donut position in degree
        self.fieldX, self.fieldY = fieldXY

        # Save the initial image if we want the compensator always start from this
        self.image0 = None

        # We will need self.fldr to be on denominator
        self.fldr = np.max((np.hypot(self.fieldX, self.fieldY), 1e-8))

        # Check the type of image
        if atype.lower() not in (self.INTRA, self.EXTRA):
            raise TypeError("Image defocal type must be 'intra' or 'extra'.")
        self.atype = atype

        # Coefficient to do the off-axis correction
        self.offAxis_coeff = None

        # Defocal distance (Baseline is 1.5mm. The configuration file now is 1mm.)
        self.offAxisOffset = 0

        # Check the image has the problem or not
        self.caustic = False

        # Reset all mask related parameters
        self.pMask = None
        self.cMask = None

    def updateImage0(self):
        """
        
        Update the backup of initial image. This will be used in the outer loop iteration, which
        always uses the initial image (image0) before each iteration starts.
        """

        # Update the initial image for future use
        self.image0 = self.__image.image.copy()

    def imageCoCenter(self, inst, fov=3.5, debugLevel=0):
        """
        
        Shift the weighting center of donut to the center of reference image with the correction of 
        projection of fieldX and fieldY.

        Arguments:
            inst {[Instrument]} -- Instrument to use.
        
        Keyword Arguments:
            fov {[float]} -- Field of view (FOV) of telescope. (default: {3.5})
            debugLevel {[int]} -- Show the information under the running. If the value is higher, 
                                the information shows more. It can be 0, 1, 2, or 3. (default: {0})
        """

        # Calculate the weighting center (x, y) and radius
        x1, y1 = self.getCenterAndR_ef()[0:2]

        # Show the co-center information
        if (debugLevel >= 3):
            print("imageCoCenter: (x, y) = (%8.2f,%8.2f)\n" % (x1, y1))

        # Calculate the center position on image
        # 0.5 is the half of 1 pixel
        sensorSamples = inst.parameter["sensorSamples"]
        stampCenterx1 = sensorSamples / 2. + 0.5
        stampCentery1 = sensorSamples / 2. + 0.5

        # Shift in the radial direction
        # The field of view (FOV) of LSST camera is 3.5 degree
        offset = inst.parameter["offset"]
        pixelSize = inst.parameter["pixelSize"]
        radialShift = fov * (offset / 1e-3) * (10e-6 / pixelSize)

        # Calculate the projection of distance of donut to center
        radialShift = radialShift * (self.fldr / (fov / 2))

        # Do not consider the condition out of FOV of lsst
        if (self.fldr > (fov / 2)):
            radialShift = 0

        # Calculate the cos(theta) for projection
        I1c = self.fieldX / self.fldr

        # Calculate the sin(theta) for projection
        I1s = self.fieldY / self.fldr

        # Get the projected x, y-coordinate
        stampCenterx1 = stampCenterx1 + radialShift * I1c
        stampCentery1 = stampCentery1 + radialShift * I1s

        # Shift the image to the projected position
        self.__image.updateImage(
            np.roll(self.__image.image,
                    int(np.round(stampCentery1 - y1)),
                    axis=0))
        self.__image.updateImage(
            np.roll(self.__image.image,
                    int(np.round(stampCenterx1 - x1)),
                    axis=1))

    def compensate(self, inst, algo, zcCol, model):
        """
        
        Calculate the image compensated from the affection of wavefront.
        
        Arguments:
            inst {[Instrument]} -- Instrument to use.
            algo {[Algorithm]} -- Algorithm to solve the Poisson's equation. It can by done 
                                  by the fast Fourier transform or serial expansion.
            zcCol {[float]} -- Coefficients of wavefront.
            model {[string]} -- Optical model. It can be "paraxial", "onAxis", or "offAxis".
        
        Raises:
            Exception -- Number of terms of normal/ annular Zernike polynomilas does 
                         not match the needed number for compensation to use.
        """

        # Check the condition of inputs
        numTerms = algo.parameter["numTerms"]
        if ((zcCol.ndim == 1) and (len(zcCol) != numTerms)):
            raise RuntimeError(
                "input:size",
                "zcCol in compensate needs to be a %d row column vector. \n" %
                numTerms)

        # Dimension of image
        sm, sn = self.__image.image.shape

        # Dimenstion of projected image on focal plane
        projSamples = sm

        # Let us create a look-up table for x -> xp first.
        luty, lutx = np.mgrid[-(projSamples / 2 - 0.5):(projSamples / 2 + 0.5),
                              -(projSamples / 2 - 0.5):(projSamples / 2 + 0.5)]

        sensorFactor = inst.parameter["sensorFactor"]
        lutx = lutx / (projSamples / 2 / sensorFactor)
        luty = luty / (projSamples / 2 / sensorFactor)

        # Set up the mapping
        lutxp, lutyp, J = self.__aperture2image(inst, algo, zcCol, lutx, luty,
                                                projSamples, model)

        show_lutxyp = self.__showProjection(lutxp,
                                            lutyp,
                                            sensorFactor,
                                            projSamples,
                                            raytrace=False)
        if (np.all(show_lutxyp <= 0)):
            self.caustic = True
            return

        # Calculate the weighting center (x, y) and radius
        realcx, realcy = self.__image.getCenterAndR_ef()[0:2]

        # Extend the dimension of image by 20 pixel in x and y direction
        show_lutxyp = padArray(show_lutxyp, projSamples + 20)

        # Get the binary matrix of image on pupil plane if raytrace=False
        struct0 = generate_binary_structure(2, 1)
        struct = iterate_structure(struct0, 4)
        struct = binary_dilation(struct, structure=struct0,
                                 iterations=2).astype(int)
        show_lutxyp = binary_dilation(show_lutxyp, structure=struct)
        show_lutxyp = binary_erosion(show_lutxyp, structure=struct)

        # Extract the region from the center of image and get the original one
        show_lutxyp = extractArray(show_lutxyp, projSamples)

        # Calculate the weighting center (x, y) and radius
        projcx, projcy = self.__image.getCenterAndR_ef(
            image=show_lutxyp.astype(float))[0:2]

        # Shift the image to center of projection on pupil
        # +(-) means we need to move image to the right (left)
        shiftx = projcx - realcx
        # +(-) means we need to move image upward (downward)
        shifty = projcy - realcy

        self.__image.image = np.roll(self.__image.image,
                                     int(np.round(shifty)),
                                     axis=0)
        self.__image.image = np.roll(self.__image.image,
                                     int(np.round(shiftx)),
                                     axis=1)

        # Construct the interpolant to get the intensity on (x', p') plane
        # that corresponds to the grid points on (x,y)
        yp, xp = np.mgrid[-(sm / 2 - 0.5):(sm / 2 + 0.5),
                          -(sm / 2 - 0.5):(sm / 2 + 0.5)]

        xp = xp / (sm / 2 / sensorFactor)
        yp = yp / (sm / 2 / sensorFactor)

        # Put the NaN to be 0 for the interpolate to use
        lutxp[np.isnan(lutxp)] = 0
        lutyp[np.isnan(lutyp)] = 0

        # Construct the function for interpolation
        ip = RectBivariateSpline(yp[:, 0],
                                 xp[0, :],
                                 self.__image.image,
                                 kx=1,
                                 ky=1)

        # Construct the projected image by the interpolation
        lutIp = np.zeros(lutxp.shape[0] * lutxp.shape[1])
        for ii, (xx, yy) in enumerate(zip(lutxp.ravel(), lutyp.ravel())):
            lutIp[ii] = ip(yy, xx)
        lutIp = lutIp.reshape(lutxp.shape)

        # Calaculate the image on focal plane with compensation based on flux conservation
        # I(x, y)/I'(x', y') = J = (dx'/dx)*(dy'/dy) - (dx'/dy)*(dy'/dx)
        self.__image.image = lutIp * J

        if (self.atype == "extra"):
            self.__image.image = np.rot90(self.__image.image, k=2)

        # Put NaN to be 0
        self.__image.image[np.isnan(self.__image.image)] = 0

        # Check the compensated image has the problem or not.
        # The negative value means the over-compensation from wavefront error
        if (np.any(self.__image.image < 0) and np.all(self.image0 >= 0)):
            print(
                "WARNING: negative scale parameter, image is within caustic, zcCol (in um)=\n"
            )
            self.caustic = True

        # Put the overcompensated part to be 0.
        self.__image.image[self.__image.image < 0] = 0

    def __aperture2image(self, inst, algo, zcCol, lutx, luty, projSamples,
                         model):
        """
        
        Calculate the x, y-coordinate on the focal plane and the related Jacobian matrix.
        
        Arguments:
            inst {[Instrument]} -- Instrument to use.
            algo {[Algorithm]} -- Algorithm to solve the Poisson's equation. It can by done 
                                  by the fast Fourier transform or serial expansion.
            zcCol {[float]} -- Coefficients of optical basis. It is Zernike polynomials in the 
                               baseline.
            lutx {[float]} -- x-coordinate on pupil plane.
            luty {[float]} -- y-coordinate on pupil plane.
            projSamples {[int]} -- Dimension of projected image. This value considers the
                                   magnification ratio of donut image.
            model {[string]} -- Optical model. It can be "paraxial", "onAxis", or "offAxis".
        
        Returns:
            [float] -- x, y-coordinate on the focal plane.
            [float] -- Jacobian matrix between the pupil and focal plane.
        """

        # Get the radius: R = D/2
        R = inst.parameter["apertureDiameter"] / 2.0

        # Calculate C = -f(f-l)/l/R^2. This is for the calculation of reduced coordinate.
        if (self.atype == self.INTRA):
            l = inst.parameter["offset"]
        elif (self.atype == self.EXTRA):
            l = -inst.parameter["offset"]
        focalLength = inst.parameter["focalLength"]
        myC = -focalLength * (focalLength - l) / l / R**2

        # Get the functions to do the off-axis correction by numerical fitting
        # Order to do the off-axis correction. The order is 10 now.
        offAxisPolyOrder = algo.parameter["offAxisPolyOrder"]
        polyFunc = self.__getFunction("poly%d_2D" % offAxisPolyOrder)
        polyGradFunc = self.__getFunction("poly%dGrad" % offAxisPolyOrder)

        # Calculate the distance to center
        lutr = np.sqrt(lutx**2 + luty**2)

        # Calculated the extended ring radius (delta r), which is to extended the available
        # pupil area.
        # 1 pixel larger than projected pupil. No need to be EF-like, anything
        # outside of this will be masked off by the computational mask
        sensorFactor = inst.parameter["sensorFactor"]
        onepixel = 1 / (projSamples / 2 / sensorFactor)

        # Get the index that the point is out of the range of extended pupil
        obscuration = inst.parameter["obscuration"]
        idxout = (lutr > 1 + onepixel) | (lutr < obscuration - onepixel)

        # Define the element to be NaN if it is out of range
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the index in the extended area of outer boundary with the width of onepixel
        idxbound = (lutr <= 1 + onepixel) & (lutr > 1)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=1)
        lutx[idxbound] = lutx[idxbound] / lutr[idxbound]
        luty[idxbound] = luty[idxbound] / lutr[idxbound]

        # Get the index in the extended area of inner boundary with the width of onepixel
        idxinbd = (lutr < obscuration) & (lutr > obscuration - onepixel)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=obscuration)
        lutx[idxinbd] = lutx[idxinbd] / lutr[idxinbd] * obscuration
        luty[idxinbd] = luty[idxinbd] / lutr[idxinbd] * obscuration

        # Get the corrected x, y-coordinate on focal plane (lutxp, lutyp)
        if (model == "paraxial"):
            # No correction is needed in "paraxial" model
            lutxp = lutx
            lutyp = luty

        elif (model == "onAxis"):

            # Calculate F(x, y) = m * sqrt(f^2-R^2) / sqrt(f^2-(x^2+y^2)*R^2)
            # m is the mask scaling factor
            myA2 = (focalLength**2 - R**2) / (focalLength**2 - lutr**2 * R**2)

            # Put the unphysical value as NaN
            myA = myA2.copy()
            idx = (myA < 0)
            myA[idx] = np.nan
            myA[~idx] = np.sqrt(myA2[~idx])

            # Mask scaling factor (for fast beam)
            maskScalingFactor = algo.parameter["maskScalingFactor"]

            # Calculate the x, y-coordinate on focal plane
            # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)
            lutxp = maskScalingFactor * myA * lutx
            lutyp = maskScalingFactor * myA * luty

        elif (model == "offAxis"):

            # Get the coefficient of polynomials for off-axis correction
            tt = self.offAxisOffset

            cx = (self.offAxis_coeff[0, :] - self.offAxis_coeff[2, :]) * (tt+l)/(2*tt) + \
                    self.offAxis_coeff[2, :]
            cy = (self.offAxis_coeff[1, :] - self.offAxis_coeff[3, :]) * (tt+l)/(2*tt) + \
                    self.offAxis_coeff[3, :]

            # This will be inverted back by typesign later on.
            # We do the inversion here to make the (x,y)->(x',y') equations has
            # the same form as the paraxial case.
            cx = np.sign(l) * cx
            cy = np.sign(l) * cy

            # Do the orthogonalization: x'=1/sqrt(2)*(x+y), y'=1/sqrt(2)*(x-y)
            # Calculate the rotation angle for the orthogonalization
            costheta = (self.fieldX + self.fieldY) / self.fldr / np.sqrt(2)
            if (costheta > 1):
                costheta = 1
            elif (costheta < -1):
                costheta = -1

            sintheta = np.sqrt(1 - costheta**2)
            if (self.fieldY < self.fieldX):
                sintheta = -sintheta

            # Create the pupil grid in off-axis model. This gives the x,y-coordinate
            # in the extended ring area defined by the parameter of onepixel.

            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self.__interpMaskParam(
                self.fieldX, self.fieldY, inst.maskParam)

            lutx, luty = self.__createPupilGrid(lutx, luty, onepixel, maskCa,
                                                maskCb, maskRa, maskRb,
                                                self.fieldX, self.fieldY)

            # Calculate the x, y-coordinate on focal plane

            # First rotate back to reference orientation
            lutx0 = lutx * costheta + luty * sintheta
            luty0 = -lutx * sintheta + luty * costheta

            # Use the mapping at reference orientation
            lutxp0 = polyFunc(cx, lutx0, y=luty0)
            lutyp0 = polyFunc(cy, lutx0, y=luty0)

            # Rotate back to focal plane
            lutxp = lutxp0 * costheta - lutyp0 * sintheta
            lutyp = lutxp0 * sintheta + lutyp0 * costheta

            # Zemax data are in mm, therefore 1000
            sensorSamples = inst.parameter["sensorSamples"]
            pixelSize = inst.parameter["pixelSize"]
            reduced_coordi_factor = 1e-3 / (sensorSamples / 2 * pixelSize /
                                            sensorFactor)

            # Reduced coordinates, so that this can be added with the dW/dz
            lutxp = lutxp * reduced_coordi_factor
            lutyp = lutyp * reduced_coordi_factor

        else:
            print('Wrong optical model type in compensate. \n')
            return

        # Obscuration of annular aperture
        zobsR = algo.parameter["zobsR"]

        # Calculate the x, y-coordinate on focal plane
        # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)

        # In Model basis (zer: Zernike polynomials)
        if (zcCol.ndim == 1):
            lutxp = lutxp + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                     "dx")
            lutyp = lutyp + myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                     "dy")

        # Make the sign to be consistent
        if (self.atype == "extra"):
            lutxp = -lutxp
            lutyp = -lutyp

        # Calculate the Jacobian matrix
        # In Model basis (zer: Zernike polynomials)
        if (zcCol.ndim == 1):
            if (model == "paraxial"):
                J = 1 + myC * ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "1st") + \
                    myC**2 * ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "2nd")

            elif (model == "onAxis"):
                xpox = maskScalingFactor * myA * (1 + \
                    lutx**2 * R**2. / (focalLength**2 - R**2 * lutr**2)) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx2")

                ypoy = maskScalingFactor * myA * (1 + \
                    luty**2 * R**2. / (focalLength**2 - R**2 * lutr**2)) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy2")

                xpoy = maskScalingFactor * myA * \
                    lutx * luty * R**2 / (focalLength**2 - R**2 * lutr**2) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dxy")

                ypox = xpoy

                J = xpox * ypoy - xpoy * ypox

            elif (model == "offAxis"):
                xp0ox = polyGradFunc(cx, lutx0, luty0, "dx") * costheta - \
                        polyGradFunc(cx, lutx0, luty0, "dy") * sintheta

                yp0ox = polyGradFunc(cy, lutx0, luty0, "dx") * costheta - \
                        polyGradFunc(cy, lutx0, luty0, "dy") * sintheta

                xp0oy = polyGradFunc(cx, lutx0, luty0, "dx") * sintheta + \
                        polyGradFunc(cx, lutx0, luty0, "dy") * costheta

                yp0oy = polyGradFunc(cy, lutx0, luty0, "dx") * sintheta + \
                        polyGradFunc(cy, lutx0, luty0, "dy") * costheta

                xpox = (xp0ox*costheta - yp0ox*sintheta)*reduced_coordi_factor + \
                        myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx2")

                ypoy = (xp0oy*sintheta + yp0oy*costheta)*reduced_coordi_factor + \
                        myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy2")

                temp = myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR,
                                                "dxy")

                # if temp==0,xpoy doesn't need to be symmetric about x=y
                xpoy = (xp0oy * costheta -
                        yp0oy * sintheta) * reduced_coordi_factor + temp

                # xpoy-flipud(rot90(ypox))==0 is true
                ypox = (xp0ox * sintheta +
                        yp0ox * costheta) * reduced_coordi_factor + temp

                J = xpox * ypoy - xpoy * ypox

        return lutxp, lutyp, J

    def __getFunction(self, name):
        """
        
        Decide to call the function of __poly10_2D() or __poly10Grad(). This is to correct 
        the off-axis distortion. A numerical solution with 2-dimensions 10 order polynomials 
        to map between the telescope aperature and defocused image plane is used.
        
        Arguments:
            name {[string]} -- Function name to call.
        
        Returns:
            [float] -- Corrected image after the correction.
        
        Raises:
            RuntimeError -- Raise error if the function name does not exist.
        """

        # Construnct the dictionary table for calling function.
        # The reason to use the dictionary is for the future's extension.
        funcTable = dict(poly10_2D=self.__poly10_2D,
                         poly10Grad=self.__poly10Grad)

        # Look for the function to call
        if name in funcTable:
            return funcTable[name]

        # Error for unknown function name
        raise RuntimeError("Unknown function name: %s" % name)

    def __poly10_2D(self, c, data, y=None):
        """
        
        Correct the off-axis distortion by fitting with a 10 order polynomial 
        equation. 
        
        Arguments:
            c {[float]} -- Parameters of off-axis distrotion.
            data {[float]} -- x, y-coordinate on aperature. If y is provided, 
                              this will be just the x-coordinate.
        
        Keyword Arguments:
            y {[float]} -- y-coordinate at aperature (default: {None}).
        
        Returns:
            [float] -- Corrected parameters for off-axis distortion.
        """

        # Decide the x, y-coordinate data on aperature
        if (y is None):
            x = data[0, :]
            y = data[1, :]
        else:
            x = data

        # Correct the off-axis distortion
        return poly10_2D(c, x.flatten(), y.flatten()).reshape(x.shape)

    def __poly10Grad(self, c, x, y, atype):
        """
        
        Correct the off-axis distortion by fitting with a 10 order polynomial 
        equation in the gradident part. 
        
        Arguments:
            c {[float]} -- Parameters of off-axis distrotion.
            x {[type]} -- x-coordinate at aperature.
            y {[float]} -- y-coordinate at aperature.
            atype {[string]} -- Direction of gradient. It can be "dx" or "dy".
        
        Returns:
            [float] -- Corrected parameters for off-axis distortion.
        """

        return poly10Grad(c, x.flatten(), y.flatten(), atype).reshape(x.shape)

    def __createPupilGrid(self,
                          lutx,
                          luty,
                          onepixel,
                          ca,
                          cb,
                          ra,
                          rb,
                          fieldX,
                          fieldY=None):
        """
        
        Create the pupil grid in off-axis model. This function gives the x,y-coordinate in the 
        extended ring area defined by the parameter of onepixel.

        Arguments:
            lutx {[float]} -- x-coordinate on pupil plane.
            luty {[float]} -- y-coordinate on pupil plane.
            onepixel {[float]} -- Exteneded delta radius.
            ca {[float]} -- Center of outer ring on the pupil plane.
            cb {float} -- Center of inner ring on the pupil plane.
            ra {[float]} -- Radius of outer ring on the pupil plane.
            rb {[float]} -- Radius of inner ring on the pupil plane.
            fieldX {[float]} -- x-coordinate of donut on the focal plane in degree.
                                If only fieldX is given, this will be fldr = sqrt(2)*fieldX
                                actually.
        
        Keyword Arguments:
            fieldY {[float]} -- y-coordinate of donut on the focal plane in degree. (default: {None})
        
        Returns:
            [float] -- x, y-coordinate of extended ring area on pupil plane.
        """

        # Calculate fieldX, fieldY if only input of fieldX (= fldr = sqrt(2)*fieldX actually)
        # is provided
        if (fieldY is None):
            # Input of filedX is fldr actually
            fldr = fieldX
            # Divide fldr by sqrt(2) to get fieldX = fieldY
            fieldX = fldr / np.sqrt(2)
            fieldY = fieldX

        # Rotate the mask center after the off-axis correction based on the position
        # of fieldX and fieldY
        cax, cay, cbx, cby = self.__rotateMaskParam(ca, cb, fieldX, fieldY)

        # Get x, y coordinate of extended outer boundary by the linear approximation
        lutx, luty = self.__approximateExtendedXY(lutx, luty, cax, cay, ra,
                                                  ra + onepixel, "outer")

        # Get x, y coordinate of extended inner boundary by the linear approximation
        lutx, luty = self.__approximateExtendedXY(lutx, luty, cbx, cby,
                                                  rb - onepixel, rb, "inner")

        return lutx, luty

    def __approximateExtendedXY(self, lutx, luty, cenX, cenY, innerR, outerR,
                                config):
        """
        
        Calculate the x, y-cooridnate on puil plane in the extended ring area by the linear
        approxination, which is used in the off-axis correction. 
        
        Arguments:
            lutx {[float]} -- x-coordinate on pupil plane.
            luty {[float]} -- y-coordinate on pupil plane.
            cenX {[float]} -- x-coordinate of boundary ring center.
            cenY {[float]} -- y-coordinate of boundary ring center.
            innerR {[float]} -- Inner radius of extended ring.
            outerR {[float]} -- Outer radius of extended ring.
            config {[string]} -- Configuration to calculate the x,y-coordinate in the extended ring.
                                 "inner": inner extended ring; 
                                 "outer": outer extended ring.
        
        Returns:
            [float] -- x, y-coordinate of extended ring area on pupil plane.
        """

        # Catculate the distance to rotated center of boundary ring
        lutr = np.sqrt((lutx - cenX)**2 + (luty - cenY)**2)

        # Define NaN to be 999 for the comparison in the following step
        tmp = lutr.copy()
        tmp[np.isnan(tmp)] = 999

        # Get the available index that the related distance is between innderR and outerR
        idxbound = (~np.isnan(lutr)) & (tmp >= innerR) & (tmp <= outerR)

        # Deside R based on the configuration
        if (config == "outer"):
            R = innerR
            # Get the index that the related distance is bigger than outerR
            idxout = (tmp > outerR)
        elif (config == "inner"):
            R = outerR
            # Get the index that the related distance is smaller than innerR
            idxout = (tmp < innerR)

        # Put the x, y-coordiate to be NaN if it is inside/ outside the pupil that is
        # after the off-axis correction.
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the x, y-coordinate in this ring area by the linear approximation
        lutx[idxbound] = (lutx[idxbound] - cenX) / lutr[idxbound] * R + cenX
        luty[idxbound] = (luty[idxbound] - cenY) / lutr[idxbound] * R + cenY

        return lutx, luty

    def __rotateMaskParam(self, ca, cb, fieldX, fieldY):
        """
        
        Rotate the mask-related parameters of center.
        
        Arguments:
            ca {[float]} -- Mask-related parameter of center.
            cb {float} -- Mask-related parameter of center.
            fieldX {[float]} -- x-coordinate of donut on the focal plane in degree.
            fieldY {[float]} -- y-coordinate of donut on the focal plane in degree.
        
        Returns:
            [float] -- Projected x, y elements
        """

        # Calculate the sin(theta) and cos(theta) for the rotation
        fldr = np.sqrt(fieldX**2 + fieldY**2)
        if (fldr == 0):
            c = 0
            s = 0
        else:
            # Calculate cos(theta)
            c = fieldX / fldr

            # Calculate sin(theta)
            s = fieldY / fldr

        # Projected x and y coordinate after the rotation
        cax = c * ca
        cay = s * ca

        cbx = c * cb
        cby = s * cb

        return cax, cay, cbx, cby

    def getOffAxisCorr(self, instDir, order):
        """
        
        Map the coefficients of off-axis correction for x, y-projection of intra- and 
        extra-image. This is for the mapping of coordinate from the telescope apearature 
        to defocal image plane.
        
        Arguments:
            instDir {[string]} -- Path to specific instrument directory.
            order {[int]} -- Up to order-th of off-axis correction.
        """

        # List of configuration
        configList = ["cxin", "cyin", "cxex", "cyex"]

        # Get all files in the directory
        fileList = [
            f for f in os.listdir(instDir)
            if os.path.isfile(os.path.join(instDir, f))
        ]

        # Read files
        temp = []

        for config in configList:

            # Construct the configuration file name
            for fileName in fileList:
                m = re.match(r"\S*%s\S*.txt" % config, fileName)
                if (m is not None):
                    matchFileName = m.group()
                    break
            filePath = os.path.join(instDir, matchFileName)

            # Read the file
            corr_coeff, offset = self.__getOffAxisCorr_single(filePath)
            temp.append(corr_coeff)

        # Give the values
        self.offAxis_coeff = np.array(temp)
        self.offAxisOffset = offset

    def __getOffAxisCorr_single(self, confFile):
        """
        
        Get the image-related pamameters for the off-axis distortion by the linear 
        approximation with a series of fitted parameters with LSST ZEMAX model.
        
        Arguments:
            confFile {[string]} -- Path of configuration file.
        
        Returns:
            [float] -- Coefficients for the off-axis distortion based on the linear 
                       response.
            [float] -- Defocal distance in m.
        """

        # Calculate the distance from donut to origin (aperature)
        fldr = np.sqrt(self.fieldX**2 + self.fieldY**2)

        # Read the configuration file
        cdata = np.loadtxt(confFile)

        # Record the offset (defocal distance)
        offset = cdata[0, 0]

        # Take the reference parameters
        c = cdata[:, 1:]

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(c[:, 0]**2 + c[:, 1]**2)

        # Get the fitted parameters for off-axis correction by linear approximation
        corr_coeff = self.__linearApprox(fldr, ruler, c[:, 2:])

        return corr_coeff, offset

    def __interpMaskParam(self, fieldX, fieldY, maskParam):
        """
        
        Get the mask-related pamameters for the off-axis distortion and vignetting correction 
        by the linear approximation with a series of fitted parameters with LSST ZEMAX model.
        
        Arguments:
            fieldX {[float]} -- x-coordinate of donut on the focal plane in degree.
            fieldY {[float]} -- y-coordinate of donut on the focal plane in degree.
            maskParam {[string]} -- Fitted coefficient file for the off-axis distortion and 
                                    vignetting correction 
        
        Returns:
            [float] -- Coefficients for the off-axis distortion and vignetting correction based 
                       on the linear response.
        """

        # Calculate the distance from donut to origin (aperature)
        fldr = np.sqrt(fieldX**2 + fieldY**2)

        # Load the mask parameter
        c = np.loadtxt(maskParam)

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(2) * c[:, 0]

        # Get the fitted parameters for off-axis correction by linear approximation
        param = self.__linearApprox(fldr, ruler, c[:, 1:])

        # Define related parameters
        ca = param[0]
        ra = param[1]
        cb = param[2]
        rb = param[3]

        return ca, ra, cb, rb

    def __linearApprox(self, fldr, ruler, parameters):
        """
        
        Get the fitted parameters for off-axis correction by linear approximation
        
        Arguments:
            fldr {[float]} -- Distance from donut to origin (aperature).
            ruler {[float]} -- A series of distance with available parameters for the fitting.
            parameters {[float]} -- Referenced parameters for the fitting.
        
        Returns:
            [float] -- Fitted parameters based on the linear approximation.
        """

        # Sort the ruler and parameters based on the magnitude of ruler
        sortIndex = np.argsort(ruler)
        ruler = ruler[sortIndex]
        parameters = parameters[sortIndex, :]

        # Compare the distance to center (aperature) between donut and standard
        compDis = (ruler >= fldr)

        # fldr is too big and out of range
        if (fldr > ruler.max()):
            # Take the coefficients in the highest boundary
            p2 = parameters.shape[0] - 1
            p1 = 0
            w1 = 0
            w2 = 1

        # fldr is too small to be in the range
        elif (fldr < ruler.min()):
            # Take the coefficients in the lowest boundary
            p2 = 0
            p1 = 0
            w1 = 1
            w2 = 0

        # fldr is in the range
        else:
            # Find the boundary of fldr in the known data
            p2 = compDis.argmax()
            p1 = p2 - 1

            # Calculate the weighting ratio
            w1 = (ruler[p2] - fldr) / (ruler[p2] - ruler[p1])
            w2 = 1 - w1

        # Get the fitted parameters for off-axis correction by linear approximation
        param = w1 * parameters[p1, :] + w2 * parameters[p2, :]

        return param

    def makeMaskList(self, inst, model):
        """
        
        Calculate the mask list based on the obscuration and optical model.
        
        Arguments:
            inst {[Instrument]} -- Instrument to use.
            model {[string]} -- Optical model. It can be "paraxial", "onAxis", or "offAxis".
        """

        # Masklist = [center_x, center_y, radius_of_boundary, 1/ 0 for outer/ inner boundary]
        obscuration = inst.parameter["obscuration"]
        if (model in ("paraxial", "onAxis")):

            if (obscuration == 0):
                masklist = np.array([0, 0, 1, 1])
            else:
                masklist = np.array([[0, 0, 1, 1], [0, 0, obscuration, 0]])
        else:
            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self.__interpMaskParam(
                self.fieldX, self.fieldY, inst.maskParam)

            # Rotate the mask-related parameters of center
            cax, cay, cbx, cby = self.__rotateMaskParam(
                maskCa, maskCb, self.fieldX, self.fieldY)
            masklist = np.array([[0, 0, 1, 1], [0, 0, obscuration, 0],
                                 [cax, cay, maskRa, 1], [cbx, cby, maskRb, 0]])

        return masklist

    def __showProjection(self,
                         lutxp,
                         lutyp,
                         sensorFactor,
                         projSamples,
                         raytrace=False):
        """
        
        Calculate the x, y-projection of image on pupil. This can be used to calculate 
        the center of projection in compensate().
        
        Arguments:
            lutxp {[float]} -- x-coordinate on pupil plane. The value of element will be 
                               NaN if that point is not inside the pupil.
            lutyp {[float]} -- y-coordinate on pupil plane. The value of element will be 
                               NaN if that point is not inside the pupil.
            sensorFactor {[float]} -- ? (Need to check the meaning of this.)
            projSamples {[int]} -- Dimension of projected image. This value considers the
                                   magnification ratio of donut image.
            raytrace {[bool]} -- Consider the ray trace or not. If the value is true, the 
                                 times of photon hit will aggregate. (default: {False})
        
        Returns:
            [float] -- Projection of image. It will be a binary image if raytrace=False.
        """

        # Dimension of pupil image
        n1, n2 = lutxp.shape

        # Construct the binary matrix on pupil. It is noted that if the raytrace is true,
        # the value of element is allowed to be greater than 1.
        show_lutxyp = np.zeros([n1, n2])

        # Get the index in pupil. If a point's value is NaN, this point is outside the pupil.
        idx = (~np.isnan(lutxp)).nonzero()
        for ii, jj in zip(idx[0], idx[1]):
            # Calculate the projected x, y-coordinate in pixel
            # x=0.5 is center of pixel#1
            xR = int(
                np.round((lutxp[ii, jj] + sensorFactor) * projSamples /
                         sensorFactor / 2 + 0.5))
            yR = int(
                np.round((lutyp[ii, jj] + sensorFactor) * projSamples /
                         sensorFactor / 2 + 0.5))

            # Check the projected coordinate is in the range of image or not.
            # If the check passes, the times will be recorded.
            if (xR > 0 and xR < n2 and yR > 0 and yR < n1):
                # Aggregate the times
                if raytrace:
                    show_lutxyp[yR - 1, xR - 1] += 1
                # No aggragation of times
                else:
                    if (show_lutxyp[yR - 1, xR - 1] < 1):
                        show_lutxyp[yR - 1, xR - 1] = 1

        return show_lutxyp

    def makeMask(self, inst, model, boundaryT, maskScalingFactorLocal):
        """
        
        Get the binary mask which considers the obscuration and off-axis correction.
        There will be two mask parameters to be calculated:
        pMask: padded mask for use at the offset planes
        cMask: non-padded mask corresponding to aperture
        
        Arguments:
            inst {[Instrument]} -- Instrument to use.
            model {[string]} -- Optical model. It can be "paraxial", "onAxis", or "offAxis".
            boundaryT {[int]} -- Extended boundary in pixel. It defines how far the 
                                 computation mask extends beyond the pupil mask. And, 
                                 in fft, it is also the width of Neuman boundary where 
                                 the derivative of the wavefront is set to zero.
            maskScalingFactorLocal {[float]} -- Mask scaling factor (for fast beam) for
                                                local correction.
        """

        sensorSamples = inst.parameter["sensorSamples"]
        self.pMask = np.ones(sensorSamples, dtype=int)
        self.cMask = self.pMask.copy()

        apertureDiameter = inst.parameter["apertureDiameter"]
        focalLength = inst.parameter["focalLength"]
        offset = inst.parameter["offset"]
        rMask = apertureDiameter / (2 * focalLength /
                                    offset) * maskScalingFactorLocal

        # Get the mask list
        masklist = self.makeMaskList(inst, model)

        for ii in range(masklist.shape[0]):

            # Distance to center on pupil
            r = np.sqrt((inst.xSensor - masklist[ii, 0])**2 +
                        (inst.ySensor - masklist[ii, 1])**2)

            # Find the indices that correspond to the mask element, set them to
            # the pass/ block boolean

            # Get the index inside the aperature
            idx = (r <= masklist[ii, 2])

            # Get the higher and lower boundary beyond the pupil mask by extension.
            # The extension level is dicided by boundaryT.
            # In fft, this is also the Neuman boundary where the derivative of the
            # wavefront is set to zero.
            pixelSize = inst.parameter["pixelSize"]
            if (masklist[ii, 3] >= 1):
                aidx = np.nonzero(r <= masklist[ii, 2] *
                                  (1 + boundaryT * pixelSize / rMask))
            else:
                aidx = np.nonzero(r <= masklist[ii, 2] *
                                  (1 - boundaryT * pixelSize / rMask))

            # Initialize both mask elements to the opposite of the pass/ block boolean
            pMaskii = (1 - masklist[ii, 3]) * \
                        np.ones([sensorSamples, sensorSamples], dtype=int)
            cMaskii = pMaskii.copy()

            pMaskii[idx] = masklist[ii, 3]
            cMaskii[aidx] = masklist[ii, 3]

            # Multiplicatively add the current mask elements to the model masks.
            # This is try to find the common mask region.

            # padded mask for use at the offset planes
            self.pMask = self.pMask * pMaskii
            # non-padded mask corresponding to aperture
            self.cMask = self.cMask * cMaskii
Exemple #7
0
class CompensableImage(object):

    def __init__(self):
        """Instantiate the class of CompensableImage."""

        self._image = Image()
        self.defocalType = DefocalType.Intra

        # Field coordinate in degree
        self.fieldX = 0
        self.fieldY = 0

        # Initial image before doing the compensation
        self.image0 = None

        # Coefficient to do the off-axis correction
        self.offAxisCoeff = np.array([])

        # Defocused offset in files of off-axis correction
        self.offAxisOffset = 0.0

        # Ture if the image gets the over-compensation
        self.caustic = False

        # Padded mask for use at the offset planes
        self.pMask = np.array([], dtype=int)

        # Non-padded mask corresponding to aperture
        self.cMask = np.array([], dtype=int)

    def getDefocalType(self):
        """Get the defocal type.

        Returns
        -------
        enum 'DefocalType'
            Defocal type.
        """

        return self.defocalType

    def getImgObj(self):
        """Get the image object.

        Returns
        -------
        Image
            Imgae object.
        """

        return self._image

    def getImg(self):
        """Get the image.

        Returns
        -------
        numpy.ndarray
            Image.
        """

        return self._image.getImg()

    def getImgSizeInPix(self):
        """Get the image size in pixel.

        Returns
        -------
        int
            Image size in pixel.
        """

        return self.getImg().shape[0]

    def getOffAxisCoeff(self):
        """Get the coefficients to do the off-axis correction.

        Returns
        -------
        numpy.ndarray
            Coefficients to do the off-axis correction.
        float
            Defocused offset in files of off-axis correction.
        """

        return self.offAxisCoeff, self.offAxisOffset

    def getImgInit(self):
        """Get the initial image before doing the compensation.

        Returns
        -------
        numpy.ndarray
            Initial image before doing the compensation.
        """

        return self.image0

    def isCaustic(self):
        """The image is caustic or not.

        The image might be caustic from the over-compensation.

        Returns
        -------
        bool
            True if the image is caustic.
        """

        return self.caustic

    def getPaddedMask(self):
        """Get the padded mask use at the offset planes.

        Returns
        -------
        numpy.ndarray[int]
            Padded mask.
        """

        return self.pMask

    def getNonPaddedMask(self):
        """Get the non-padded mask corresponding to aperture.

        Returns
        -------
        numpy.ndarray[int]
            Non-padded mask
        """

        return self.cMask

    def getFieldXY(self):
        """Get the field x, y in degree.

        Returns
        -------
        float
            Field x in degree.
        float
            Field y in degree.
        """

        return self.fieldX, self.fieldY

    def setImg(self, fieldXY, defocalType, image=None, imageFile=None):
        """Set the wavefront image.

        Parameters
        ----------
        fieldXY : tuple or list
            Position of donut on the focal plane in degree (field x, field y).
        defocalType : enum 'DefocalType'
            Defocal type of image.
        image : numpy.ndarray, optional
            Array of image. (the default is None.)
        imageFile : str, optional
            Path of image file. (the default is None.)
        """

        self._image.setImg(image=image, imageFile=imageFile)
        self._checkImgShape()

        self.fieldX, self.fieldY = fieldXY
        self.defocalType = defocalType

        self._resetInternalAttributes()

    def _checkImgShape(self):
        """Check the image shape.

        Raises
        ------
        RuntimeError
            Only square image stamps are accepted.
        RuntimeError
            Number of pixels cannot be odd numbers.
        """

        img = self._image.getImg()
        if (img.shape[0] != img.shape[1]):
            raise RuntimeError("Only square image stamps are accepted.")
        elif (img.shape[0] % 2 == 1):
            raise RuntimeError("Number of pixels cannot be odd numbers.")

    def _resetInternalAttributes(self):
        """Reset the internal attributes."""

        self.image0 = None

        self.offAxisCoeff = np.array([])
        self.offAxisOffset = 0.0

        self.caustic = False

        # Reset all mask related parameters
        self.pMask = np.array([], dtype=int)
        self.cMask = np.array([], dtype=int)

    def updateImage(self, image):
        """Update the image of donut.

        Parameters
        ----------
        image : numpy.ndarray
            Donut image.
        """

        self._image.updateImage(image)

    def updateImgInit(self):
        """Update the backup of initial image.

        This will be used in the outer loop iteration, which always uses the
        initial image (image0) before each iteration starts.
        """

        # Update the initial image for future use
        self.image0 = self._image.getImg().copy()

    def imageCoCenter(self, inst, fov=3.5, debugLevel=0):
        """Shift the weighting center of donut to the center of reference
        image with the correction of projection of fieldX and fieldY.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        fov : float, optional
            Field of view (FOV) of telescope. (the default is 3.5.)
        debugLevel : int, optional
            Show the information under the running. If the value is higher, the
            information shows more. It can be 0, 1, 2, or 3. (the default is
            0.)
        """

        # Calculate the weighting center (x, y) and radius
        x1, y1 = self._image.getCenterAndR_ef()[0:2]

        # Show the co-center information
        if (debugLevel >= 3):
            print("imageCoCenter: (x, y) = (%8.2f,%8.2f)\n" % (x1, y1))

        # Calculate the center position on image
        # 0.5 is the half of 1 pixel
        dimOfDonut = inst.getDimOfDonutOnSensor()
        stampCenterx1 = dimOfDonut / 2 + 0.5
        stampCentery1 = dimOfDonut / 2 + 0.5

        # Shift in the radial direction
        # The field of view (FOV) of LSST camera is 3.5 degree
        offset = inst.getDefocalDisOffset()
        pixelSize = inst.getCamPixelSize()
        radialShift = fov*(offset/1e-3)*(10e-6/pixelSize)

        # Calculate the projection of distance of donut to center
        fieldDist = self._getFieldDistFromOrigin()
        radialShift = radialShift * (fieldDist / (fov / 2))

        # Do not consider the condition out of FOV of lsst
        if (fieldDist > (fov / 2)):
            radialShift = 0

        # Calculate the cos(theta) for projection
        I1c = self.fieldX / fieldDist

        # Calculate the sin(theta) for projection
        I1s = self.fieldY / fieldDist

        # Get the projected x, y-coordinate
        stampCenterx1 = stampCenterx1 + radialShift*I1c
        stampCentery1 = stampCentery1 + radialShift*I1s

        # Shift the image to the projected position
        self._image.updateImage(
            np.roll(self._image.getImg(), int(np.round(stampCentery1 - y1)), axis=0))
        self._image.updateImage(
            np.roll(self._image.getImg(), int(np.round(stampCenterx1 - x1)), axis=1))

    def _getFieldDistFromOrigin(self, fieldX=None, fieldY=None, minDist=1e-8):
        """Get the field distance from the origin.

        Parameters
        ----------
        fieldX : float, optional
            Field x in degree. If the input is None, the value of self.fieldX
            will be used. (the default is None.)
        fieldY : float, optional
            Field y in degree. If the input is None, the value of self.fieldY
            will be used. (the default is None.)
        minDist : float, optional
            Minimum distace. In some cases, the field distance will be the
            denominator in other functions. (the default is 1e-8.)

        Returns
        -------
        float
            Field distance from the origin.
        """

        if (fieldX is None):
            fieldX = self.fieldX

        if (fieldY is None):
            fieldY = self.fieldY

        fieldDist = np.hypot(fieldX, fieldY)
        if (fieldDist == 0):
            fieldDist = minDist

        return fieldDist

    def compensate(self, inst, algo, zcCol, model):
        """Calculate the image compensated from the affection of wavefront.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        algo : Algorithm
            Algorithm to solve the Poisson's equation. It can by done by the
            fast Fourier transform or serial expansion.
        zcCol : numpy.ndarray
            Coefficients of wavefront.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Raises
        ------
        RuntimeError
            input:size zcCol in compensate needs to be a numTerms row column
            vector.
        """

        # Check the condition of inputs
        numTerms = algo.getNumOfZernikes()
        if ((zcCol.ndim == 1) and (len(zcCol) != numTerms)):
            raise RuntimeError("input:size",
                               "zcCol in compensate needs to be a %d row column vector. \n" % numTerms)

        # Dimension of image
        sm, sn = self._image.getImg().shape

        # Dimenstion of projected image on focal plane
        projSamples = sm

        # Let us create a look-up table for x -> xp first.
        luty, lutx = np.mgrid[-(projSamples/2 - 0.5):(projSamples/2 + 0.5),
                              -(projSamples/2 - 0.5):(projSamples/2 + 0.5)]

        sensorFactor = inst.getSensorFactor()
        lutx = lutx/(projSamples/2/sensorFactor)
        luty = luty/(projSamples/2/sensorFactor)

        # Set up the mapping
        lutxp, lutyp, J = self._aperture2image(inst, algo, zcCol, lutx, luty,
                                               projSamples, model)

        show_lutxyp = self._showProjection(lutxp, lutyp, sensorFactor,
                                           projSamples, raytrace=False)
        if (np.all(show_lutxyp <= 0)):
            self.caustic = True
            return

        # Calculate the weighting center (x, y) and radius
        realcx, realcy = self._image.getCenterAndR_ef()[0:2]

        # Extend the dimension of image by 20 pixel in x and y direction
        show_lutxyp = padArray(show_lutxyp, projSamples+20)

        # Get the binary matrix of image on pupil plane if raytrace=False
        struct0 = generate_binary_structure(2, 1)
        struct = iterate_structure(struct0, 4)
        struct = binary_dilation(struct, structure=struct0, iterations=2).astype(int)
        show_lutxyp = binary_dilation(show_lutxyp, structure=struct)
        show_lutxyp = binary_erosion(show_lutxyp, structure=struct)

        # Extract the region from the center of image and get the original one
        show_lutxyp = extractArray(show_lutxyp, projSamples)

        # Calculate the weighting center (x, y) and radius
        projcx, projcy = self._image.getCenterAndR_ef(image=show_lutxyp.astype(float))[0:2]

        # Shift the image to center of projection on pupil
        # +(-) means we need to move image to the right (left)
        shiftx = projcx - realcx
        # +(-) means we need to move image upward (downward)
        shifty = projcy - realcy

        self._image.updateImage(np.roll(self._image.getImg(), int(np.round(shifty)), axis=0))
        self._image.updateImage(np.roll(self._image.getImg(), int(np.round(shiftx)), axis=1))

        # Construct the interpolant to get the intensity on (x', p') plane
        # that corresponds to the grid points on (x,y)
        yp, xp = np.mgrid[-(sm/2 - 0.5):(sm/2 + 0.5), -(sm/2 - 0.5):(sm/2 + 0.5)]

        xp = xp/(sm/2/sensorFactor)
        yp = yp/(sm/2/sensorFactor)

        # Put the NaN to be 0 for the interpolate to use
        lutxp[np.isnan(lutxp)] = 0
        lutyp[np.isnan(lutyp)] = 0

        # Construct the function for interpolation
        ip = RectBivariateSpline(yp[:, 0], xp[0, :], self._image.getImg(), kx=1, ky=1)

        # Construct the projected image by the interpolation
        lutIp = np.zeros(lutxp.shape[0]*lutxp.shape[1])
        for ii, (xx, yy) in enumerate(zip(lutxp.ravel(), lutyp.ravel())):
            lutIp[ii] = ip(yy, xx)
        lutIp = lutIp.reshape(lutxp.shape)

        # Calaculate the image on focal plane with compensation based on flux
        # conservation
        # I(x, y)/I'(x', y') = J = (dx'/dx)*(dy'/dy) - (dx'/dy)*(dy'/dx)
        self._image.updateImage(lutIp * J)

        if (self.defocalType == DefocalType.Extra):
            self._image.updateImage(np.rot90(self._image.getImg(), k=2))

        # Put NaN to be 0
        holdedImg = self._image.getImg()
        holdedImg[np.isnan(holdedImg)] = 0
        self._image.updateImage(holdedImg)

        # Check the compensated image has the problem or not.
        # The negative value means the over-compensation from wavefront error
        if (np.any(self._image.getImg() < 0) and np.all(self.image0 >= 0)):
            print("WARNING: negative scale parameter, image is within caustic, zcCol (in um)=\n")
            self.caustic = True

        # Put the overcompensated part to be 0
        holdedImg = self._image.getImg()
        holdedImg[holdedImg < 0] = 0
        self._image.updateImage(holdedImg)

    def _aperture2image(self, inst, algo, zcCol, lutx, luty, projSamples,
                        model):
        """Calculate the x, y-coordinate on the focal plane and the related
        Jacobian matrix.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        algo : Algorithm
            Algorithm to solve the Poisson's equation. It can by done by the
            fast Fourier transform or serial expansion.
        zcCol : numpy.ndarray
            Coefficients of optical basis. It is Zernike polynomials in the
            baseline.
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        projSamples : int
            Dimension of projected image. This value considers the
            magnification ratio of donut image.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Returns
        -------
        numpy.ndarray
            X coordinate on the focal plane.
        numpy.ndarray
            Y coordinate on the focal plane.
        numpy.ndarray
            Jacobian matrix between the pupil and focal plane.
        """

        # Get the radius: R = D/2
        R = inst.getApertureDiameter() / 2

        # Calculate C = -f(f-l)/l/R^2. This is for the calculation of reduced
        # coordinate.
        defocalDisOffset = inst.getDefocalDisOffset()
        if (self.defocalType == DefocalType.Intra):
            l = defocalDisOffset
        elif (self.defocalType == DefocalType.Extra):
            l = -defocalDisOffset

        focalLength = inst.getFocalLength()
        myC = -focalLength*(focalLength - l)/l/R**2

        # Get the functions to do the off-axis correction by numerical fitting
        # Order to do the off-axis correction. The order is 10 now.
        offAxisPolyOrder = algo.getOffAxisPolyOrder()
        polyFunc = self._getFunction("poly%d_2D" % offAxisPolyOrder)
        polyGradFunc = self._getFunction("poly%dGrad" % offAxisPolyOrder)

        # Calculate the distance to center
        lutr = np.sqrt(lutx**2 + luty**2)

        # Calculated the extended ring radius (delta r), which is to extended
        # the available pupil area.
        # 1 pixel larger than projected pupil. No need to be EF-like, anything
        # outside of this will be masked off by the computational mask
        sensorFactor = inst.getSensorFactor()
        onepixel = 1/(projSamples/2/sensorFactor)

        # Get the index that the point is out of the range of extended pupil
        obscuration = inst.getObscuration()
        idxout = (lutr > 1+onepixel) | (lutr < obscuration-onepixel)

        # Define the element to be NaN if it is out of range
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the index in the extended area of outer boundary with the width
        # of onepixel
        idxbound = (lutr <= 1+onepixel) & (lutr > 1)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=1)
        lutx[idxbound] = lutx[idxbound]/lutr[idxbound]
        luty[idxbound] = luty[idxbound]/lutr[idxbound]

        # Get the index in the extended area of inner boundary with the width
        # of onepixel
        idxinbd = (lutr < obscuration) & (lutr > obscuration-onepixel)

        # Calculate the extended x, y-coordinate (x' = x/r*r', r'=obscuration)
        lutx[idxinbd] = lutx[idxinbd]/lutr[idxinbd]*obscuration
        luty[idxinbd] = luty[idxinbd]/lutr[idxinbd]*obscuration

        # Get the corrected x, y-coordinate on focal plane (lutxp, lutyp)
        if (model == "paraxial"):
            # No correction is needed in "paraxial" model
            lutxp = lutx
            lutyp = luty

        elif (model == "onAxis"):

            # Calculate F(x, y) = m * sqrt(f^2-R^2) / sqrt(f^2-(x^2+y^2)*R^2)
            # m is the mask scaling factor
            myA2 = (focalLength**2 - R**2) / (focalLength**2 - lutr**2 * R**2)

            # Put the unphysical value as NaN
            myA = myA2.copy()
            idx = (myA < 0)
            myA[idx] = np.nan
            myA[~idx] = np.sqrt(myA2[~idx])

            # Mask scaling factor (for fast beam)
            maskScalingFactor = algo.getMaskScalingFactor()

            # Calculate the x, y-coordinate on focal plane
            # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)
            lutxp = maskScalingFactor*myA*lutx
            lutyp = maskScalingFactor*myA*luty

        elif (model == "offAxis"):

            # Get the coefficient of polynomials for off-axis correction
            tt = self.offAxisOffset

            cx = (self.offAxisCoeff[0, :] - self.offAxisCoeff[2, :]) * (tt+l)/(2*tt) + \
                self.offAxisCoeff[2, :]
            cy = (self.offAxisCoeff[1, :] - self.offAxisCoeff[3, :]) * (tt+l)/(2*tt) + \
                self.offAxisCoeff[3, :]

            # This will be inverted back by typesign later on.
            # We do the inversion here to make the (x,y)->(x',y') equations has
            # the same form as the paraxial case.
            cx = np.sign(l)*cx
            cy = np.sign(l)*cy

            # Do the orthogonalization: x'=1/sqrt(2)*(x+y), y'=1/sqrt(2)*(x-y)
            # Calculate the rotation angle for the orthogonalization
            fieldDist = self._getFieldDistFromOrigin()
            costheta = (self.fieldX + self.fieldY) / fieldDist / np.sqrt(2)
            if (costheta > 1):
                costheta = 1
            elif (costheta < -1):
                costheta = -1

            sintheta = np.sqrt(1 - costheta**2)
            if (self.fieldY < self.fieldX):
                sintheta = -sintheta

            # Create the pupil grid in off-axis model. This gives the
            # x,y-coordinate in the extended ring area defined by the parameter
            # of onepixel.

            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self._interpMaskParam(
                self.fieldX, self.fieldY, inst.getMaskOffAxisCorr())

            lutx, luty = self._createPupilGrid(
                lutx, luty, onepixel, maskCa, maskCb, maskRa, maskRb,
                self.fieldX, self.fieldY)

            # Calculate the x, y-coordinate on focal plane

            # First rotate back to reference orientation
            lutx0 = lutx*costheta + luty*sintheta
            luty0 = -lutx*sintheta + luty*costheta

            # Use the mapping at reference orientation
            lutxp0 = polyFunc(cx, lutx0, y=luty0)
            lutyp0 = polyFunc(cy, lutx0, y=luty0)

            # Rotate back to focal plane
            lutxp = lutxp0*costheta - lutyp0*sintheta
            lutyp = lutxp0*sintheta + lutyp0*costheta

            # Zemax data are in mm, therefore 1000
            dimOfDonut = inst.getDimOfDonutOnSensor()
            pixelSize = inst.getCamPixelSize()
            reduced_coordi_factor = 1e-3/(dimOfDonut/2*pixelSize/sensorFactor)

            # Reduced coordinates, so that this can be added with the dW/dz
            lutxp = lutxp*reduced_coordi_factor
            lutyp = lutyp*reduced_coordi_factor

        else:
            print('Wrong optical model type in compensate. \n')
            return

        # Obscuration of annular aperture
        zobsR = algo.getObsOfZernikes()

        # Calculate the x, y-coordinate on focal plane
        # x' = F(x,y)*x + C*(dW/dx), y' = F(x,y)*y + C*(dW/dy)

        # In Model basis (zer: Zernike polynomials)
        if (zcCol.ndim == 1):
            lutxp = lutxp + myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx")
            lutyp = lutyp + myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy")

        # Make the sign to be consistent
        if (self.defocalType == DefocalType.Extra):
            lutxp = -lutxp
            lutyp = -lutyp

        # Calculate the Jacobian matrix
        # In Model basis (zer: Zernike polynomials)
        if (zcCol.ndim == 1):
            if (model == "paraxial"):
                J = 1 + myC * ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "1st") + \
                    myC**2 * ZernikeAnnularJacobian(zcCol, lutx, luty, zobsR, "2nd")

            elif (model == "onAxis"):
                xpox = maskScalingFactor * myA * (
                    1 + lutx**2 * R**2. / (focalLength**2 - R**2 * lutr**2)) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx2")

                ypoy = maskScalingFactor * myA * (
                    1 + luty**2 * R**2. / (focalLength**2 - R**2 * lutr**2)) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy2")

                xpoy = maskScalingFactor * myA * \
                    lutx * luty * R**2 / (focalLength**2 - R**2 * lutr**2) + \
                    myC * ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dxy")

                ypox = xpoy

                J = xpox*ypoy - xpoy*ypox

            elif (model == "offAxis"):
                xp0ox = polyGradFunc(cx, lutx0, luty0, "dx") * costheta - \
                    polyGradFunc(cx, lutx0, luty0, "dy") * sintheta

                yp0ox = polyGradFunc(cy, lutx0, luty0, "dx") * costheta - \
                    polyGradFunc(cy, lutx0, luty0, "dy") * sintheta

                xp0oy = polyGradFunc(cx, lutx0, luty0, "dx") * sintheta + \
                    polyGradFunc(cx, lutx0, luty0, "dy") * costheta

                yp0oy = polyGradFunc(cy, lutx0, luty0, "dx") * sintheta + \
                    polyGradFunc(cy, lutx0, luty0, "dy") * costheta

                xpox = (xp0ox*costheta - yp0ox*sintheta)*reduced_coordi_factor + \
                    myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dx2")

                ypoy = (xp0oy*sintheta + yp0oy*costheta)*reduced_coordi_factor + \
                    myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dy2")

                temp = myC*ZernikeAnnularGrad(zcCol, lutx, luty, zobsR, "dxy")

                # if temp==0,xpoy doesn't need to be symmetric about x=y
                xpoy = (xp0oy*costheta - yp0oy*sintheta)*reduced_coordi_factor + temp

                # xpoy-flipud(rot90(ypox))==0 is true
                ypox = (xp0ox*sintheta + yp0ox*costheta)*reduced_coordi_factor + temp

                J = xpox*ypoy - xpoy*ypox

        return lutxp, lutyp, J

    def _getFunction(self, name):
        """Decide to call the function of _poly10_2D() or _poly10Grad().

        This is to correct the off-axis distortion. A numerical solution with
        2-dimensions 10 order polynomials to map between the telescope
        aperature and defocused image plane is used.

        Parameters
        ----------
        name : str
            Function name to call.

        Returns
        -------
        numpy.ndarray
            Corrected image after the correction.

        Raises
        ------
        RuntimeError
            Raise error if the function name does not exist.
        """

        # Construnct the dictionary table for calling function.
        # The reason to use the dictionary is for the future's extension.
        funcTable = dict(poly10_2D=self._poly10_2D, poly10Grad=self._poly10Grad)

        # Look for the function to call
        if name in funcTable:
            return funcTable[name]

        # Error for unknown function name
        raise RuntimeError("Unknown function name: %s" % name)

    def _poly10_2D(self, c, data, y=None):
        """Correct the off-axis distortion by fitting with a 10 order
        polynomial equation.

        Parameters
        ----------
        c : numpy.ndarray
            Parameters of off-axis distrotion.
        data : numpy.ndarray
            X, y-coordinate on aperature. If y is provided this will be just
            the x-coordinate.
        y : numpy.ndarray, optional
            Y-coordinate at aperature. (the default is None.)

        Returns
        -------
        numpy.ndarray
            Corrected parameters for off-axis distortion.
        """

        # Decide the x, y-coordinate data on aperature
        if (y is None):
            x = data[0, :]
            y = data[1, :]
        else:
            x = data

        # Correct the off-axis distortion
        return poly10_2D(c, x.flatten(), y.flatten()).reshape(x.shape)

    def _poly10Grad(self, c, x, y, atype):
        """Correct the off-axis distortion by fitting with a 10 order
        polynomial equation in the gradident part.

        Parameters
        ----------
        c : numpy.ndarray
            Parameters of off-axis distrotion.
        x : numpy.ndarray
            X-coordinate at aperature.
        y : numpy.ndarray
            Y-coordinate at aperature.
        atype : str
            Direction of gradient. It can be "dx" or "dy".

        Returns
        -------
        numpy.ndarray
            Corrected parameters for off-axis distortion.
        """

        return poly10Grad(c, x.flatten(), y.flatten(), atype).reshape(x.shape)

    def _createPupilGrid(self, lutx, luty, onepixel, ca, cb, ra, rb, fieldX,
                         fieldY):
        """Create the pupil grid in off-axis model.

        This function gives the x,y-coordinate in the extended ring area
        defined by the parameter of onepixel.

        Parameters
        ----------
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        onepixel : float
            Exteneded delta radius.
        ca : float
            Center of outer ring on the pupil plane.
        cb : float
            Center of inner ring on the pupil plane.
        ra : float
            Radius of outer ring on the pupil plane.
        rb : float
            Radius of inner ring on the pupil plane.
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.

        Returns
        -------
        numpy.ndarray
            X-coordinate of extended ring area on pupil plane.
        numpy.ndarray
            Y-coordinate of extended ring area on pupil plane.
        """

        # Rotate the mask center after the off-axis correction based on the
        # position of fieldX and fieldY
        cax, cay, cbx, cby = self._rotateMaskParam(ca, cb, fieldX, fieldY)

        # Get x, y coordinate of extended outer boundary by the linear
        # approximation
        lutx, luty = self._approximateExtendedXY(lutx, luty, cax, cay, ra,
                                                 ra+onepixel, "outer")

        # Get x, y coordinate of extended inner boundary by the linear
        # approximation
        lutx, luty = self._approximateExtendedXY(lutx, luty, cbx, cby,
                                                 rb-onepixel, rb, "inner")

        return lutx, luty

    def _approximateExtendedXY(self, lutx, luty, cenX, cenY, innerR, outerR,
                               config):
        """Calculate the x, y-cooridnate on puil plane in the extended ring
        area by the linear approxination, which is used in the off-axis
        correction.

        Parameters
        ----------
        lutx : numpy.ndarray
            X-coordinate on pupil plane.
        luty : numpy.ndarray
            Y-coordinate on pupil plane.
        cenX : float
            X-coordinate of boundary ring center.
        cenY : float
            Y-coordinate of boundary ring center.
        innerR : float
            Inner radius of extended ring.
        outerR : float
            Outer radius of extended ring.
        config : str
            Configuration to calculate the x,y-coordinate in the extended ring.
            "inner": inner extended ring; "outer": outer extended ring.

        Returns
        -------
        numpy.ndarray
            X-coordinate of extended ring area on pupil plane.
        numpy.ndarray
            Y-coordinate of extended ring area on pupil plane.
        """

        # Catculate the distance to rotated center of boundary ring
        lutr = np.sqrt((lutx - cenX)**2 + (luty - cenY)**2)

        # Define NaN to be 999 for the comparison in the following step
        tmp = lutr.copy()
        tmp[np.isnan(tmp)] = 999

        # Get the available index that the related distance is between innderR
        # and outerR
        idxbound = (~np.isnan(lutr)) & (tmp >= innerR) & (tmp <= outerR)

        # Deside R based on the configuration
        if (config == "outer"):
            R = innerR
            # Get the index that the related distance is bigger than outerR
            idxout = (tmp > outerR)
        elif (config == "inner"):
            R = outerR
            # Get the index that the related distance is smaller than innerR
            idxout = (tmp < innerR)

        # Put the x, y-coordiate to be NaN if it is inside/ outside the pupil
        # that is after the off-axis correction.
        lutx[idxout] = np.nan
        luty[idxout] = np.nan

        # Get the x, y-coordinate in this ring area by the linear approximation
        lutx[idxbound] = (lutx[idxbound]-cenX)/lutr[idxbound]*R + cenX
        luty[idxbound] = (luty[idxbound]-cenY)/lutr[idxbound]*R + cenY

        return lutx, luty

    def _rotateMaskParam(self, ca, cb, fieldX, fieldY):
        """Rotate the mask-related parameters of center.

        Parameters
        ----------
        ca : float
            Mask-related parameter of center.
        cb : float
            Mask-related parameter of center.
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.

        Returns
        -------
        float
            Projected x element after the rotation.
        float
            Projected y element after the rotation.
        float
            Projected x element after the rotation.
        float
            Projected y element after the rotation.
        """

        # Calculate the sin(theta) and cos(theta) for the rotation
        fieldDist = self._getFieldDistFromOrigin(fieldX=fieldX, fieldY=fieldY,
                                                 minDist=0)
        if (fieldDist == 0):
            c = 0
            s = 0
        else:
            # Calculate cos(theta)
            c = fieldX / fieldDist

            # Calculate sin(theta)
            s = fieldY / fieldDist

        # Projected x and y coordinate after the rotation
        cax = c * ca
        cay = s * ca

        cbx = c * cb
        cby = s * cb

        return cax, cay, cbx, cby

    def setOffAxisCorr(self, inst, order):
        """Set the coefficients of off-axis correction for x, y-projection of
        intra- and extra-image.

        This is for the mapping of coordinate from the telescope apearature to
        defocal image plane.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        order : int
            Up to order-th of off-axis correction.
        """

        # List of configuration
        configList = ["cxin", "cyin", "cxex", "cyex"]

        # Get all files in the directory
        instDir = inst.getInstFileDir()
        fileList = [f for f in os.listdir(instDir)
                    if os.path.isfile(os.path.join(instDir, f))]

        # Read files
        offAxisCoeff = []
        for config in configList:

            # Construct the configuration file name
            for fileName in fileList:
                m = re.match(r"\S*%s\S*.yaml" % config, fileName)
                if (m is not None):
                    matchFileName = m.group()
                    break

            filePath = os.path.join(instDir, matchFileName)
            corrCoeff, offset = self._getOffAxisCorrSingle(filePath)
            offAxisCoeff.append(corrCoeff)

        # Give the values
        self.offAxisCoeff = np.array(offAxisCoeff)
        self.offAxisOffset = offset

    def _getOffAxisCorrSingle(self, confFile):
        """Get the image-related pamameters for the off-axis distortion by the
        linear approximation with a series of fitted parameters with LSST
        ZEMAX model.

        Parameters
        ----------
        confFile : str
            Path of configuration file.

        Returns
        -------
        numpy.ndarray
            Coefficients for the off-axis distortion based on the linear
            response.
        float
            Defocal distance in m.
        """

        fieldDist = self._getFieldDistFromOrigin(minDist=0.0)

        # Read the configuration file
        paramReader = ParamReader()
        paramReader.setFilePath(confFile)
        cdata = paramReader.getMatContent()

        # Record the offset (defocal distance)
        offset = cdata[0, 0]

        # Take the reference parameters
        c = cdata[:, 1:]

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(c[:, 0]**2 + c[:, 1]**2)

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        corr_coeff = self._linearApprox(fieldDist, ruler, c[:, 2:])

        return corr_coeff, offset

    def _interpMaskParam(self, fieldX, fieldY, maskParam):
        """Get the mask-related pamameters for the off-axis distortion and
        vignetting correction by the linear approximation with a series of
        fitted parameters with LSST ZEMAX model.

        Parameters
        ----------
        fieldX : float
            X-coordinate of donut on the focal plane in degree.
        fieldY : float
            Y-coordinate of donut on the focal plane in degree.
        maskParam : numpy.ndarray
            Fitted coefficients for the off-axis distortion and vignetting
            correction.

        Returns
        -------
        float
            'ca' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'ra' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'cb' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        float
            'rb' coefficient for the off-axis distortion and vignetting
            correction based on the linear response.
        """

        # Calculate the distance from donut to origin (aperature)
        filedDist = np.sqrt(fieldX**2 + fieldY**2)

        # Get the ruler, which is the distance to center
        # ruler is between 1.51 and 1.84 degree here
        ruler = np.sqrt(2)*maskParam[:, 0]

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        param = self._linearApprox(filedDist, ruler, maskParam[:, 1:])

        # Define related parameters
        ca = param[0]
        ra = param[1]
        cb = param[2]
        rb = param[3]

        return ca, ra, cb, rb

    def _linearApprox(self, fieldDist, ruler, parameters):
        """Get the fitted parameters for off-axis correction by linear
        approximation.

        Parameters
        ----------
        fieldDist : float
            Field distance from donut to origin (aperature).
        ruler : numpy.ndarray
            A series of distance with available parameters for the fitting.
        parameters : numpy.ndarray
            Referenced parameters for the fitting.

        Returns
        -------
        numpy.ndarray
            Fitted parameters based on the linear approximation.
        """

        # Sort the ruler and parameters based on the magnitude of ruler
        sortIndex = np.argsort(ruler)
        ruler = ruler[sortIndex]
        parameters = parameters[sortIndex, :]

        # Compare the distance to center (aperature) between donut and standard
        compDis = (ruler >= fieldDist)

        # fieldDist is too big and out of range
        if (fieldDist > ruler.max()):
            # Take the coefficients in the highest boundary
            p2 = parameters.shape[0] - 1
            p1 = 0
            w1 = 0
            w2 = 1

        # fieldDist is too small to be in the range
        elif (fieldDist < ruler.min()):
            # Take the coefficients in the lowest boundary
            p2 = 0
            p1 = 0
            w1 = 1
            w2 = 0

        # fieldDist is in the range
        else:
            # Find the boundary of fieldDist in the known data
            p2 = compDis.argmax()
            p1 = p2 - 1

            # Calculate the weighting ratio
            w1 = (ruler[p2]-fieldDist)/(ruler[p2]-ruler[p1])
            w2 = 1-w1

        # Get the fitted parameters for off-axis correction by linear
        # approximation
        param = w1*parameters[p1, :] + w2*parameters[p2, :]

        return param

    def makeMaskList(self, inst, model):
        """Calculate the mask list based on the obscuration and optical model.

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".

        Returns
        -------
        numpy.ndarray
            The list of mask.
        """

        # Masklist = [center_x, center_y, radius_of_boundary,
        #             1/ 0 for outer/ inner boundary]
        obscuration = inst.getObscuration()
        if (model in ("paraxial", "onAxis")):

            if (obscuration == 0):
                masklist = np.array([0, 0, 1, 1])
            else:
                masklist = np.array([[0, 0, 1, 1],
                                    [0, 0, obscuration, 0]])
        else:
            # Get the mask-related parameters
            maskCa, maskRa, maskCb, maskRb = self._interpMaskParam(
                self.fieldX, self.fieldY, inst.getMaskOffAxisCorr())

            # Rotate the mask-related parameters of center
            cax, cay, cbx, cby = self._rotateMaskParam(
                maskCa, maskCb, self.fieldX, self.fieldY)
            masklist = np.array([[0, 0, 1, 1], [0, 0, obscuration, 0],
                                 [cax, cay, maskRa, 1], [cbx, cby, maskRb, 0]])

        return masklist

    def _showProjection(self, lutxp, lutyp, sensorFactor, projSamples,
                        raytrace=False):
        """Calculate the x, y-projection of image on pupil.

        This can be used to calculate the center of projection in compensate().

        Parameters
        ----------
        lutxp : numpy.ndarray
            X-coordinate on pupil plane. The value of element will be NaN if
            that point is not inside the pupil.
        lutyp : numpy.ndarray
            Y-coordinate on pupil plane. The value of element will be NaN if
            that point is not inside the pupil.
        sensorFactor : float
            Sensor factor.
        projSamples : int
            Dimension of projected image. This value considers the
            magnification ratio of donut image.
        raytrace : bool, optional
            Consider the ray trace or not. If the value is true, the times of
            photon hit will aggregate. (the default is False.)

        Returns
        -------
        numpy.ndarray
            Projection of image. It will be a binary image if raytrace=False.
        """

        # Dimension of pupil image
        n1, n2 = lutxp.shape

        # Construct the binary matrix on pupil. It is noted that if the
        # raytrace is true, the value of element is allowed to be greater
        # than 1.
        show_lutxyp = np.zeros([n1, n2])

        # Get the index in pupil. If a point's value is NaN, this point is
        # outside the pupil.
        idx = (~np.isnan(lutxp)).nonzero()
        for ii, jj in zip(idx[0], idx[1]):
            # Calculate the projected x, y-coordinate in pixel
            # x=0.5 is center of pixel#1
            xR = int(np.round((lutxp[ii, jj]+sensorFactor)*projSamples/sensorFactor/2 + 0.5))
            yR = int(np.round((lutyp[ii, jj]+sensorFactor)*projSamples/sensorFactor/2 + 0.5))

            # Check the projected coordinate is in the range of image or not.
            # If the check passes, the times will be recorded.
            if (xR > 0 and xR < n2 and yR > 0 and yR < n1):
                # Aggregate the times
                if raytrace:
                    show_lutxyp[yR-1, xR-1] += 1
                # No aggragation of times
                else:
                    if (show_lutxyp[yR-1, xR-1] < 1):
                        show_lutxyp[yR-1, xR-1] = 1

        return show_lutxyp

    def makeMask(self, inst, model, boundaryT, maskScalingFactorLocal):
        """Get the binary mask which considers the obscuration and off-axis
        correction.

        There will be two mask parameters to be calculated:
        pMask: padded mask for use at the offset planes
        cMask: non-padded mask corresponding to aperture

        Parameters
        ----------
        inst : Instrument
            Instrument to use.
        model : str
            Optical model. It can be "paraxial", "onAxis", or "offAxis".
        boundaryT : int
            Extended boundary in pixel. It defines how far the computation mask
            extends beyond the pupil mask. And, in fft, it is also the width of
            Neuman boundary where the derivative of the wavefront is set to
            zero.
        maskScalingFactorLocal : float
            Mask scaling factor (for fast beam) for local correction.
        """

        dimOfDonut = inst.getDimOfDonutOnSensor()
        self.pMask = np.ones(dimOfDonut, dtype=int)
        self.cMask = self.pMask.copy()

        apertureDiameter = inst.getApertureDiameter()
        focalLength = inst.getFocalLength()
        offset = inst.getDefocalDisOffset()
        rMask = apertureDiameter/(2*focalLength/offset)*maskScalingFactorLocal

        # Get the mask list
        pixelSize = inst.getCamPixelSize()
        xSensor, ySensor = inst.getSensorCoor()
        masklist = self.makeMaskList(inst, model)
        for ii in range(masklist.shape[0]):

            # Distance to center on pupil
            r = np.sqrt((xSensor - masklist[ii, 0])**2 +
                        (ySensor - masklist[ii, 1])**2)

            # Find the indices that correspond to the mask element, set them to
            # the pass/ block boolean

            # Get the index inside the aperature
            idx = (r <= masklist[ii, 2])

            # Get the higher and lower boundary beyond the pupil mask by
            # extension.
            # The extension level is dicided by boundaryT.
            # In fft, this is also the Neuman boundary where the derivative of
            # the wavefront is set to zero.
            if (masklist[ii, 3] >= 1):
                aidx = np.nonzero(r <= masklist[ii, 2]*(1+boundaryT*pixelSize/rMask))
            else:
                aidx = np.nonzero(r <= masklist[ii, 2]*(1-boundaryT*pixelSize/rMask))

            # Initialize both mask elements to the opposite of the pass/ block
            # boolean
            pMaskii = (1 - masklist[ii, 3]) * \
                np.ones([dimOfDonut, dimOfDonut], dtype=int)
            cMaskii = pMaskii.copy()

            pMaskii[idx] = masklist[ii, 3]
            cMaskii[aidx] = masklist[ii, 3]

            # Multiplicatively add the current mask elements to the model
            # masks.
            # This is try to find the common mask region.

            # padded mask for use at the offset planes
            self.pMask = self.pMask * pMaskii
            # non-padded mask corresponding to aperture
            self.cMask = self.cMask * cMaskii
Exemple #8
0
class TestImage(unittest.TestCase):
    """Test the Image class."""

    def setUp(self):

        self.testDataDir = os.path.join(getModulePath(), "tests", "testData")
        self.imgFile = os.path.join(self.testDataDir, "testImages",
                                    "LSST_NE_SN25", "z11_0.25_intra.txt")

        self.img = Image()
        self.img.setImg(imageFile=self.imgFile)

    def testGetImg(self):

        img = self.img.getImg()
        self.assertEqual(img.shape, (120, 120))

    def testGetImgFilePath(self):

        imgFilePath = self.img.getImgFilePath()
        self.assertEqual(imgFilePath, self.imgFile)

    def testSetImgByImageArray(self):

        newImg = np.random.rand(5, 5)
        self.img.setImg(image=newImg)

        self.assertTrue(np.all(self.img.getImg() == newImg))
        self.assertEqual(self.img.getImgFilePath(), "")

    def testSetImgByFitsFile(self):

        opdFitsFile = os.path.join(self.testDataDir, "opdOutput", "9005000",
                                   "opd_9005000_0.fits.gz")
        self.img.setImg(imageFile=opdFitsFile)

        img = self.img.getImg()
        self.assertEqual(img.shape, (255, 255))

        imgFilePath = self.img.getImgFilePath()
        self.assertEqual(imgFilePath, opdFitsFile)

    def testUpdateImage(self):

        newImg = np.random.rand(5, 5)
        self.img.updateImage(newImg)

        self.assertTrue(np.all(self.img.getImg() == newImg))

    def testUpdateImageWithNoHoldImage(self):

        img = Image()

        newImg = np.random.rand(5, 5)
        self.assertWarns(UserWarning, img.updateImage, newImg)

    def testGetCenterAndR_ef(self):

        realcx, realcy, realR, imgBinary = \
            self.img.getCenterAndR_ef(checkEntropy=True)
        self.assertEqual(int(realcx), 61)
        self.assertEqual(int(realcy), 61)
        self.assertGreater(int(realR), 35)

    def testGetCenterAndR_ef_withEntropyCheck(self):

        # Creat a zero image
        zeroImg = Image()
        zeroImg.setImg(image=np.ones([4, 4]))

        realcx, realcy, realR, imgBinary = \
            zeroImg.getCenterAndR_ef(checkEntropy=True)

        self.assertEqual(realcx, [])

        # update to the random image
        zeroImg.updateImage(np.random.rand(100, 100))
        realcx, realcy, realR, imgBinary = \
            zeroImg.getCenterAndR_ef(checkEntropy=True)
        self.assertEqual(realcx, [])

    def testGetSNR(self):

        # Add the noise to the image
        image = self.img.getImg()
        noisedImg = image + np.random.random(image.shape) * 0.1

        self.img.setImg(image=noisedImg)
        snr = self.img.getSNR()

        self.assertGreater(snr, 15)
Exemple #9
0
class TestImage(unittest.TestCase):
    """Test the Image class."""

    def setUp(self):

        self.testDataDir = os.path.join(getModulePath(), "tests", "testData")
        self.imgFile = os.path.join(
            self.testDataDir, "testImages", "LSST_NE_SN25", "z11_0.25_intra.txt"
        )

        self.img = Image()
        self.img.setImg(imageFile=self.imgFile)

    def testGetCentroidFind(self):

        centroidFind = self.img.getCentroidFind()
        self.assertTrue(isinstance(centroidFind, CentroidRandomWalk))

    def testGetImg(self):

        img = self.img.getImg()
        self.assertEqual(img.shape, (120, 120))

    def testGetImgFilePath(self):

        imgFilePath = self.img.getImgFilePath()
        self.assertEqual(imgFilePath, self.imgFile)

    def testSetImgByImageArray(self):

        newImg = np.random.rand(5, 5)
        self.img.setImg(image=newImg)

        self.assertTrue(np.all(self.img.getImg() == newImg))
        self.assertEqual(self.img.getImgFilePath(), "")

    def testSetImgByFitsFile(self):

        opdFitsFile = os.path.join(
            self.testDataDir, "opdOutput", "9006000", "opd_9006000_0.fits.gz"
        )
        self.img.setImg(imageFile=opdFitsFile)

        img = self.img.getImg()
        self.assertEqual(img.shape, (255, 255))

        imgFilePath = self.img.getImgFilePath()
        self.assertEqual(imgFilePath, opdFitsFile)

    def testUpdateImage(self):

        newImg = np.random.rand(5, 5)
        self.img.updateImage(newImg)

        self.assertTrue(np.all(self.img.getImg() == newImg))

    def testUpdateImageWithNoHoldImage(self):

        img = Image()

        newImg = np.random.rand(5, 5)
        self.assertWarns(UserWarning, img.updateImage, newImg)

    def testGetCenterAndR_ef(self):

        realcx, realcy, realR = self.img.getCenterAndR()
        self.assertEqual(int(realcx), 61)
        self.assertEqual(int(realcy), 61)
        self.assertGreater(int(realR), 35)

    def testGetSNR(self):

        # Add the noise to the image
        image = self.img.getImg()
        noisedImg = image + np.random.random(image.shape) * 0.1

        self.img.setImg(image=noisedImg)
        snr = self.img.getSNR()

        self.assertGreater(snr, 15)
Exemple #10
0
class TestImage(unittest.TestCase):
    """Test the Image class."""
    def setUp(self):

        # Get the path of module
        modulePath = getModulePath()

        # Define the algorithm folder
        algoFolderPath = os.path.join(modulePath, "configData", "cwfs", "algo")

        # Define the image folder and image names
        # Image data -- Don't know the final image format.
        # It is noted that image.readFile inuts is based on the txt file
        imageFolderPath = os.path.join(modulePath, "tests", "testData",
                                       "testImages", "LSST_NE_SN25")
        imgName = "z11_0.25_intra.txt"

        # Image files Path
        imgFile = os.path.join(imageFolderPath, imgName)

        # There is the difference between intra and extra images
        self.img = Image()
        self.img.setImg(imageFile=imgFile)

    def testZeroImg(self):

        # Creat a zero image
        zeroImg = Image()
        zeroImg.setImg(image=np.zeros([4, 4]))
        self.assertEqual(np.sum(zeroImg.image), 0)

        # Update Image
        zeroImg.updateImage(np.ones([4, 4]))
        self.assertEqual(np.sum(zeroImg.image), 16)

        realcx, realcy, realR, imgBinary = zeroImg.getCenterAndR_ef(
            randNumFilePath=None, checkEntropy=True)

        self.assertEqual(realcx, [])

        # update to the random image
        zeroImg.updateImage(np.random.rand(100, 100))
        realcx, realcy, realR, imgBinary = zeroImg.getCenterAndR_ef(
            randNumFilePath=None, checkEntropy=True)
        self.assertEqual(realcx, [])

    def testImg(self):

        realcx, realcy, realR, imgBinary = self.img.getCenterAndR_ef(
            randNumFilePath=None, checkEntropy=True)
        self.assertEqual(int(realcx), 61)
        self.assertEqual(int(realcy), 61)
        self.assertGreater(int(realR), 35)

        # Calculate the S/N
        # Add the noise to the image
        noisedImg = self.img.image + np.random.random(
            self.img.image.shape) * 0.1
        self.img.setImg(image=noisedImg)
        snr = self.img.getSNR()

        self.assertGreater(snr, 15)