Module ctsimu.evaluation.test2D_WE_1

Test 2D-WE-1: Focus spot size

Any spatially extended focal spot leads to a spatially extended point spread function (PSF) on the detector and therefore to image blurring. In this test scenario, we check if Gaussian spot intensity profiles are correctly simulated by the software to lead to the expected image blurring, represented by certain expected MTF20 values that we calculate as described below.

This scenario defines two sub-tests: one with a Gaussian spot intensity profile with a standard deviation of σ1 = 8 µm (Spot 1), and a second one with σ2 = 24 µm (Spot 2). The following code example shows how to identify each subtest correctly when using the toolbox to run the evaluation.

from ctsimu.toolbox import Toolbox
Toolbox("2D-WE-1",
    spot1 = "2D-WE-1_Spot1_metadata.json",
    spot2 = "2D-WE-1_Spot2_metadata.json")

Our evaluation approach is to measure the edge spread function (ESF) across the edge of an ideal absorber (the same edge as used for ctsimu.evaluation.test2D_WE_2). In subsequent steps, we will calculate the line spread function (LSF) to evaluate the spot size and the modulation transfer function (MTF) to determine where the system's frequency transmission drops to 20% (MTF20 value). Rossmann et al., 1969, Jones et al., 1958.

The edge spread function (ESF) is simply the line profile across the edge. The center point c of the line profile is placed exactly in the center of the vertical part of the edge (see picture below). The starting point p0 and the end point p1 of the profile result from the line profile parameters: we choose a profile length of l = 100.1 px and a bin size (resolution) of r = 0.1 px. This results in a profile of N=1001 bins along the line across the edge. We choose a profile region that is w = 200 px wide.

Line profile region of interest

Due to the pixel rasterization of the projection and the finite sampling of the line profile, we will lose frequency information even in the ideal case of a point source. We therefore use the ideal edge image from ctsimu.evaluation.test2D_WE_2 to determine the sampled LSF and MTF under the ideal assumption of a point source. This will give us the best LSF and MTF that are achievable with the given detector, edge tilt angle, and the chosen evaluation method, which means they represent our scenario's fundamental spread and transfer functions. These results are shown in the following picture.

ESF_LSF_MTF

A convolution of the fundamental ESF and LSF with the profile of a Gaussian spot will lead to the expected ideal ESF and LSF at finite spot sizes. Generally, this convolution has to be done using the point spread function (PSF), which, apart from amplitude normalization, is identical to the LSF when an isotropic Gaussian distribution of the spot's intensity is assumed. (Gopala Rao et al., 1967)

To calculate the analytical sampled LSF of an ideal non-rotated edge under a Gaussian spot with a standard deviation σ, we integrate the spot's intensity function along the full width of each bin of the line profile, assuming that the distribution is centered at s_\text{c}=\frac{N}{2} (with the number of bins N): \text{LSF}_\text{Gaussian, j} = A\cdot \int_{j-\frac{N}{2}}^{j+1-\frac{N}{2}} \text{e}^{-s^2/(2\sigma^2)} \text{d} s = A \cdot \sqrt{\frac{2}{\pi}}\sigma \cdot \left[\text{erf}\left(\frac{j+1-\frac{N}{2}}{\sqrt{2}\sigma} \right) - \text{erf}\left(\frac{j-\frac{N}{2}}{\sqrt{2}\sigma} \right) \right]. The parameter A is chosen such that the maximum of the LSF is normalized to a value of 1. The ideal expected LSF is then computed by the discrete convolution (assuming the number of bins N is an odd number) \text{LSF}_{\text{ideal}, j} = (\text{LSF}_\text{fundamental} \ast \text{LSF}_\text{Gaussian})_j = \sum_{k=0}^{N-1} \text{LSF}_{\text{fundamental}, k} \cdot \text{LSF}_{\text{Gaussian}, j-k+\frac{N-1}{2}}. In the scenario, the edge is located exactly halfway between source and detector. Therefore, no magnification of the point spread function (PSF) or line spread function (LSF) is to be expected, and the Gaussian spot size can be determined directly from the measured LSF without the need for scaling. In theory, it would be possible to use a deconvolution to remove the fundamental LSF from the measured LSF of a given simulated image such that only the shape of the spot intensity profile remains. However, deconvolution is an ill-posed problem, and it is only possible if all of our ideal assumptions are met by the simulation software. To avoid instabilities and the introduction of any additional errors, we therefore determine the expected spot sizes by Gaussian fits to the ideal LSF, i.e., from the convolution of fundamental LSF and Gaussian LSF that results from the above equation. Using a least-squares fit, we determine the expected ideal \tilde{\sigma} that are listed in the following table.

Spot size Expected LSF std. dev. Expected MTF20 frequency
σ1 = 8 μm \tilde{\sigma}_1 = 9.579(2)μm MTF201 = 29.852 cycles/mm
σ2 = 24 μm \tilde{\sigma}_2 = 24.552(1)μm MTF202 = 11.770 cycles/mm

The ideal MTF can be calculated either by multiplication of the fundamental MTF and the Gaussian MTF (as calculated from the Gaussian LSF), \text{MTF}_\text{ideal} = \text{MTF}_\text{fundamental} \cdot \text{MTF}_\text{Gaussian}, or by applying a Fourier transform to the ideal LSF. Apart from numerical artifacts, both results are identical; here, we use the second approach.

For each image to be measured, the MTF20 value is determined and compared to the MTF20 value of the ideal MTF. The MTF20 value is the frequency where the MTF drops to 20% of its maximum value. The expected values from our ideal MTFs are listed in the table above.

Expand source code
# -*- coding: UTF-8 -*-
"""# Test 2D-WE-1: Focus spot size

.. include:: ./test2D_WE_1.md
"""

from ..test import *
from ..helpers import *
from ..primitives import *
from ..image_analysis.mtf import *
from ..scenario import Scenario

from scipy import optimize

class Test2D_WE_1_results:
    """ Results for one sub test. """

    def __init__(self):
        # Profile Data:
        self.lineProfilePos      = []
        self.lineProfileGV       = []  # this will be the edge spread function (ESF)
        self.lineProfileSmoothed = []
        self.LSF                 = []  # line spread function; derivative of ESF.
        self.LSF_windowed        = []  # von-Hann window applied before FFT
        self.LSF_gaussian_fit    = []
        self.MTF                 = []
        self.MTFpos              = []
        self.fnyq                = 0   # Nyquist frequency

        self.ideal_ESF_Convolution = None      # Convolution of Gaussian with SampledESF

        self.ideal_LSF_Gaussian    = None
        self.ideal_LSF_Convolution = None

        self.ideal_MTF_Gaussian       = None   # MTF of Gaussian LSF
        self.ideal_MTF_ConvolutionFFT = None   # Ideal MTF by FFT of Convolution (Gaussian and SampledLSF)
        self.ideal_MTF_Multiplication = None   # Ideal MTF from SampledMTF * GaussianMTF; same result.

        # Fit results [px]
        self.nominalGaussianSigmaPX       = 0

        self.idealGaussianSigmaPX         = 0
        self.idealGaussianSigmaPXerror    = 0
        self.idealGaussianMuPX            = 0
        self.idealGaussianMuPXerror       = 0
        self.idealGaussianAmpPX           = 0
        self.idealGaussianAmpPXerror      = 0

        self.measuredGaussianSigmaPX      = 0
        self.measuredGaussianSigmaPXerror = 0
        self.measuredGaussianMuPX         = 0
        self.measuredGaussianMuPXerror    = 0
        self.measuredGaussianAmpPX        = 0
        self.measuredGaussianAmpPXerror   = 0

        # Fit results [mm]
        self.nominalGaussianSigmaMM       = 0

        self.idealGaussianSigmaMM         = 0
        self.idealGaussianSigmaMMerror    = 0
        self.idealGaussianMuMM            = 0
        self.idealGaussianMuMMerror       = 0
        self.idealGaussianAmpMM           = 0
        self.idealGaussianAmpMMerror      = 0

        self.measuredGaussianSigmaMM      = 0
        self.measuredGaussianSigmaMMerror = 0
        self.measuredGaussianMuMM         = 0
        self.measuredGaussianMuMMerror    = 0
        self.measuredGaussianAmpMM        = 0
        self.measuredGaussianAmpMMerror   = 0

        # MTF evaluation results
        self.MTF20freq_ideal    = 0
        self.MTF20freq_measured = 0

        #self.MTF10freq_ideal    = 0
        #self.MTF10freq_measured = 0

        self.pixelSize = 0

    def MTF20freq_absDev(self):
        return (self.MTF20freq_measured - self.MTF20freq_ideal)

    def MTF20freq_relDev(self):
        if self.MTF20freq_ideal != 0:
            return (self.MTF20freq_measured - self.MTF20freq_ideal) / self.MTF20freq_ideal

        return 0

    #def MTF10freq_absDev(self):
    #    return (self.MTF10freq_measured - self.MTF10freq_ideal)

    #def MTF10freq_relDev(self):
    #    if self.MTF10freq_ideal != 0:
    #        return (self.MTF10freq_measured - self.MTF10freq_ideal) / self.MTF10freq_ideal

        return 0

    def sigma_absDev(self):
        return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX)

    def sigma_relDev(self):
        if self.idealGaussianSigmaPX != 0:
            return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX) / self.idealGaussianSigmaPX

        return 0


class Test2D_WE_1(generalTest):
    """ CTSimU test 2D-WE-1: focal spot size. """

    def __init__(self, resultFileDirectory=".", name=None, rawOutput=False):
        generalTest.__init__(
            self,
            testName="2D-WE-1",
            name=name,
            resultFileDirectory=resultFileDirectory,
            rawOutput=rawOutput)

        self.geometry = None
        self.analyticalIntensityProfileImage = None  # analytical flat field
        self.analyticalEdgeImage             = None  # stores the analytically computed edge image (sharp image)
        self.analyticalEdgeImageFF           = None  # stores the analytically computed edge image, flat-field corrected (sharp image)

        self.results = []
        self.results_idealPointSource = Test2D_WE_1_results()

        # Start point (x0,y0) and end point (x1,y1) for line profile:
        edgeAngle = 3 * (math.pi/180.0)    # 3 deg edge rotation

        # point of origin for edge profile (i.e. profile center):
        edgeOrigin = Vector(0, 1)                            # start with unit vector pointing "down"
        edgeOrigin.rotate_2D_xy(edgeAngle)                   # rotate by edge angle
        edgeOrigin.scale(500.5 / math.cos(edgeAngle) / 2.0)  # scale to half of visible edge length
        edgeOrigin = edgeOrigin + Vector(500.5, 500.5)       # move to detector center

        #print("Edge origin:")
        #print(edgeOrigin)

        self.profileLength = 100.1   # pixels

        edgeDirection = Vector(self.profileLength/2.0, 0)
        edgeDirection.rotate_2D_xy(edgeAngle)

        self.p0 = edgeOrigin - edgeDirection
        self.p1 = edgeOrigin + edgeDirection

        #print("Line From p0:")
        #print(self.p0)
        #print("Line To p1:")
        #print(self.p1)

        self.profileWidth = 200  # pixels
        self.profileRes   = 0.1  # pixels

        # Also, prepare the clipping rectangle for the analytical
        # calculation of the ideal edge image. In pixel coordinates.
        A = Vector(   0,    0, 0)
        B = Vector(   0,  300, 0)
        C = Vector(-300,  300, 0)
        D = Vector(-300,    0, 0)

        #A.rotate_2D_xy(edgeAngle)
        B.rotate_2D_xy(edgeAngle)
        C.rotate_2D_xy(edgeAngle)
        D.rotate_2D_xy(edgeAngle)

        self.clippingRectangle = Polygon(A, B, C, D)

    def prepare(self):
        """ Preparations before the test will be run with the images from the pipeline. """
        if not isinstance(self.pipe, Pipeline):
            self.prepared = False
            raise Exception("Step must be part of a processing pipeline before it can prepare. Current pipeline: {}".format(self.pipe))

        if len(self.subtests) > 0:
            if not self.prepared:
                # It doesn't matter which of the sub-scenarios we take here.
                # They all have the same geometry.
                self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"

                if(self.jsonScenarioFile is not None):
                    self.scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                    self.geometry = self.scenario.current_geometry()
                    self.geometry.update()

                    print("Computing an analytical image for an ideal point source...")
                    self.analyticalIntensityProfileImage, self.analyticalEdgeImage = self.geometry.create_detector_flat_field_sphere(self.clippingRectangle)
                    self.analyticalEdgeImageFF = copy.deepcopy(self.analyticalEdgeImage)
                    self.analyticalEdgeImageFF.applyFlatfield(ref=self.analyticalIntensityProfileImage, rescaleFactor=60000.0)

                    # Raise analytical images to maximum grey value of 60000 before saving them.
                    # This rescaling does not affect the previous FF correction.
                    self.analyticalIntensityProfileImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)
                    self.analyticalEdgeImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)


                    # Write analytical images:
                    if self.rawOutput:
                        self.analyticalIntensityProfileImage.saveRAW("{dir}/{name}_ideal_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                        self.analyticalEdgeImage.saveRAW("{dir}/{name}_ideal_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                        self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_ideal_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    else: # TIFF
                        self.analyticalIntensityProfileImage.save("{dir}/{name}_ideal_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                        self.analyticalEdgeImage.save("{dir}/{name}_ideal_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                        self.analyticalEdgeImageFF.save("{dir}/{name}_ideal_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")


                    print("Calculating the fundamental LSF and MTF from the ideal edge image...")
                    # Calculate the edge spread function (using a line profile across the edge):
                    self.results_idealPointSource.lineProfileGV, self.results_idealPointSource.lineProfilePos, stepsize = self.analyticalEdgeImageFF.lineProfile(x0=self.p0.x(), y0=self.p0.y(), x1=self.p1.x(), y1=self.p1.y(), width=self.profileWidth, resolution=self.profileRes)

                    # Nyquist frequency determines center of MTF frequency range:
                    self.results_idealPointSource.fnyq = 1.0 / (2.0*stepsize)
                    nSamples = len(self.results_idealPointSource.lineProfileGV)
                    #self.results_idealPointSource.MTFpos = numpy.linspace(start=0, stop=2*self.results_idealPointSource.fnyq, num=nSamples, endpoint=False, dtype=numpy.float64)

                    # Make the profile positions symmetric:
                    self.results_idealPointSource.lineProfilePos -= self.profileLength / 2

                    # Calculate the line spread function and MTF.
                    self.results_idealPointSource.lineProfileSmoothed, self.results_idealPointSource.LSF, self.results_idealPointSource.LSF_windowed, self.results_idealPointSource.MTF, self.results_idealPointSource.MTFpos = MTF(positions=self.results_idealPointSource.lineProfilePos, ESF=self.results_idealPointSource.lineProfileGV)

                    self.results_idealPointSource.lineProfileDelta = self.results_idealPointSource.lineProfileSmoothed - self.results_idealPointSource.lineProfileGV

                    self.writeResultFile(subname="fundamental_spotPoint", results=self.results_idealPointSource)

                    self.prepared = True
                else:
                    raise Exception("Test 2D-WE-1: Please provide a JSON scenario description.")

    def prepareRun(self, i):
        if i < len(self.subtests):
            self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
            if self.subtests[i] == "spot1":
                self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
            elif self.subtests[i] == "spot2":
                self.jsonScenarioFile = "2D-WE-1_Spot2_2021-10-13v02r04dp.json"
            else:
                raise Exception("{key} is not a valid subtest identifier for test scenario {test}".format(key=self.subtests[i], test=self.testName))

            results = Test2D_WE_1_results()

            # Get the Gaussian spot size and the pixel size from the JSON file:
            if self.jsonScenarioFile is not None:
                scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                results.nominalGaussianSigmaMM = scenario.source.spot.sigma.u.get()
                results.pixelSize = scenario.detector.pixel_pitch.u.get()

                results.nominalGaussianSigmaPX = results.nominalGaussianSigmaMM / results.pixelSize

            if self.subtests[i] == "spotPoint":
                results.nominalGaussianSigmaMM = 0
                results.nominalGaussianSigmaPX = 0
            else:
                # Create an ideal Gaussian LSF:
                nBins = len(self.results_idealPointSource.lineProfilePos)
                sigma = results.nominalGaussianSigmaPX
                results.ideal_LSF_Gaussian = numpy.zeros_like(a=self.results_idealPointSource.lineProfilePos, dtype=numpy.dtype('float64'))
                for j in range(nBins):
                    s = self.results_idealPointSource.lineProfilePos[j]
                    sLeft  = s - self.profileRes
                    sRight = s + self.profileRes
                    results.ideal_LSF_Gaussian[j] = math.erf(sRight/(math.sqrt(2.0)*sigma)) - math.erf(sLeft/(math.sqrt(2.0)*sigma)) #gaussian((s+sNext)/2.0, 0, sigma, 1)

                # Normalize ideal LSF maximum to 1:
                idealLSFmax = numpy.amax(results.ideal_LSF_Gaussian)
                if idealLSFmax != 0:
                    results.ideal_LSF_Gaussian /= idealLSFmax

                # Calculate convolution of ideal LSF of sharp image and Gaussian LSF:
                results.ideal_LSF_Convolution = numpy.convolve(a=self.results_idealPointSource.LSF, v=results.ideal_LSF_Gaussian, mode='same')
                convMax = numpy.amax(results.ideal_LSF_Convolution)
                if convMax != 0:
                    results.ideal_LSF_Convolution /= convMax

                # Calculate ideal ESF as convolution of Gaussian with the ideal sampledESF:
                results.ideal_ESF_Convolution = numpy.convolve(a=self.results_idealPointSource.lineProfileGV, v=results.ideal_LSF_Gaussian, mode='same')
                convMax = numpy.amax(results.ideal_ESF_Convolution)
                if convMax != 0:
                    results.ideal_ESF_Convolution /= convMax

                # Fit a Gaussian to central half of the data:
                fitParametersInitial = (0, sigma, 1)
                fitPositions = self.results_idealPointSource.lineProfilePos[int(nBins//4):int(3*nBins//4)]
                #fitData = results.ideal_LSF_Gaussian #[int(nBins//4):int(3*nBins//4)]
                #popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

                #print("- Ideal Gaussian Fit Mu:    {}".format(popt[0]))
                #print("- Ideal Gaussian Fit Sigma: {}".format(popt[1]))
                #print("- Ideal Gaussian Fit A:     {}".format(popt[2]))

                fitData = results.ideal_LSF_Convolution[int(nBins//4):int(3*nBins//4)]
                popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

                perr = numpy.sqrt(numpy.diag(pcov))

                results.idealGaussianSigmaPX      = popt[1]
                results.idealGaussianSigmaPXerror = perr[1]
                results.idealGaussianMuPX         = popt[0]
                results.idealGaussianMuPXerror    = perr[0]
                results.idealGaussianAmpPX        = popt[2]
                results.idealGaussianAmpPXerror   = perr[2]

                results.idealGaussianSigmaMM      = results.pixelSize * results.idealGaussianSigmaPX
                results.idealGaussianSigmaMMerror = results.pixelSize * results.idealGaussianSigmaPXerror
                results.idealGaussianMuMM         = results.pixelSize * results.idealGaussianMuPX
                results.idealGaussianMuMMerror    = results.pixelSize * results.idealGaussianMuPXerror
                results.idealGaussianAmpMM        = results.pixelSize * results.idealGaussianAmpPX
                results.idealGaussianAmpMMerror   = results.pixelSize * results.idealGaussianAmpPXerror

                print("- Analytical LSF Fit Mu:    {} +- {}".format(popt[0], perr[0]))
                print("- Analytical LSF Fit Sigma: {} +- {}".format(popt[1], perr[1]))
                print("- Analytical LSF Fit A:     {} +- {}".format(popt[2], perr[2]))


                # Calculate MTF for Gaussian LSF and Convolution LSF:
                smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_Gaussian, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Gaussian)

                smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_ConvolutionFFT, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Convolution)

                results.ideal_MTF_Multiplication = self.results_idealPointSource.MTF * results.ideal_MTF_Gaussian
                if results.ideal_MTF_Multiplication[0] != 0:
                    results.ideal_MTF_Multiplication /= results.ideal_MTF_Multiplication[0]


            print("Gaussian Spot Size: {} mm = {} px".format(results.nominalGaussianSigmaMM, results.nominalGaussianSigmaPX))

            self.results.append(results)
        else:
            if len(self.subtests) == 0:
                raise Exception("Please provide keywords that identify which metadata file belongs to which subtest. Test {testname} accepts the following keywords: 'spot1' and 'spot2'.".format(testname=self.testName))
            else:
                raise Exception("Number of provided image metadata files exceeds number of test runs ({expected}).".format(expected=len(self.subtests)))

    def writeSummaryFile(self, subname, results):
        summaryText  = "Evaluation results for {testname} {subtest}\n".format(testname=self.testName, subtest=subname)
        summaryText += "=================================================\n\n"

        summaryText += "Fit results for gauss(x) = A * exp(-(x-mu)²/(2*sigma²))\n"
        summaryText += "Errors: 1 standard deviation\n"
        summaryText += "-------------------------------------------------------\n"
        summaryText += "measured:   sigma [px] = {} +- {}\n".format(results.measuredGaussianSigmaPX, results.measuredGaussianSigmaPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianSigmaMM, results.measuredGaussianSigmaMMerror)
        summaryText += "measured:   mu    [px] = {} +- {}\n".format(results.measuredGaussianMuPX,    results.measuredGaussianMuPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianMuMM,    results.measuredGaussianMuMMerror)
        summaryText += "measured:   A     [px] = {} +- {}\n".format(results.measuredGaussianAmpPX,   results.measuredGaussianAmpPXerror)
        summaryText += "                  [mm] = {} +- {}\n\n".format(results.measuredGaussianAmpMM, results.measuredGaussianAmpMMerror)

        summaryText += "analytical: sigma [px] = {} +- {}\n".format(results.idealGaussianSigmaPX, results.idealGaussianSigmaPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianSigmaMM, results.idealGaussianSigmaMMerror)
        summaryText += "analytical: mu    [px] = {} +- {}\n".format(results.idealGaussianMuPX,    results.idealGaussianMuPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianMuMM,    results.idealGaussianMuMMerror)
        summaryText += "analytical: A     [px] = {} +- {}\n".format(results.idealGaussianAmpPX,   results.idealGaussianAmpPXerror)
        summaryText += "                  [mm] = {} +- {}\n\n".format(results.idealGaussianAmpMM, results.idealGaussianAmpMMerror)

        summaryText += "sigma: abs. deviation (measured-analytical) [px]       = {:.5f}\n".format(results.sigma_absDev())
        summaryText += "sigma: abs. deviation (measured-analytical) [mm]       = {:.5f}\n".format(results.sigma_absDev() * results.pixelSize)
        summaryText += "sigma: rel. deviation (measured-analytical)/analytical = {:.5f}\n\n".format(results.sigma_relDev())


        #summaryText += "MTF 10% frequency\n"
        #summaryText += "-------------------------------------------------------\n"
        #summaryText += "MTF10 measured: [cycles/px] = {:.3f}\n".format(results.MTF10freq_measured)
        #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_measured / results.pixelSize)

        #summaryText += "MTF10 ideal:    [cycles/px] = {:.3f}\n".format(results.MTF10freq_ideal)
        #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_ideal / results.pixelSize)

        #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/px] = {:.5f}\n".format(results.MTF10freq_absDev())
        #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/mm] = {:.5f}\n".format(results.MTF10freq_absDev() / results.pixelSize)
        #summaryText += "MTF10: rel. deviation (measured-ideal)/ideal       = {:.5f}\n".format(results.MTF10freq_relDev())


        summaryText += "\nMTF 20% frequency\n"
        summaryText += "-------------------------------------------------------\n"
        summaryText += "MTF20 measured:   [cycles/px] = {:.5f}\n".format(results.MTF20freq_measured)
        summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_measured / results.pixelSize)

        summaryText += "MTF20 analytical: [cycles/px] = {:.5f}\n".format(results.MTF20freq_ideal)
        summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_ideal / results.pixelSize)

        summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/px] = {:.5f}\n".format(results.MTF20freq_absDev())
        summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/mm] = {:.5f}\n".format(results.MTF20freq_absDev() / results.pixelSize)
        summaryText += "MTF20: rel. deviation (measured-analytical)/analytical  = {:.5f}\n".format(results.MTF20freq_relDev())


        summaryFileName = "{dir}/{name}_{subname}_summary.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(summaryFileName, 'w') as summaryFile:
            summaryFile.write(summaryText)
            summaryFile.close()


    def writeResultFile(self, subname, results):
        pos = results.lineProfilePos

        # ESF
        ESF         = results.lineProfileGV
        ESFsmoothed = results.lineProfileSmoothed
        ESFideal    = results.ideal_ESF_Convolution

        # LSF
        LSF         = results.LSF
        LSFwindowed = results.LSF_windowed
        LSFideal    = results.ideal_LSF_Convolution

        # MTF
        MTFpos      = results.MTFpos
        MTF         = results.MTF
        MTFideal    = results.ideal_MTF_ConvolutionFFT

        profileText  = "# Profile data: edge spread function (ESF), smoothed ESF, line spread function (LSF) and windowed LSF (von-Hann window)\n"
        profileText += "# s [px]\tESF\tESF_smoothed"
        if not(ESFideal is None):
            profileText += "\tESF_ideal"

        profileText += "\tLSF\tLSF_windowed"

        if not(LSFideal is None):
            profileText += "\tLSF_ideal"

        profileText += "\n"

        mtfText  = "# Modulation Transfer Function (MTF)\n"
        mtfText += "# f [cycles/px]\tMTF_measured"
        if not(MTFideal is None):
            mtfText += "\tMTF_ideal"

        mtfText += "\n"

        for j in range(len(pos)):
            profileText += "{pos:.2f}\t{ESF}\t{ESFsmoothed}".format(pos=pos[j], ESF=ESF[j], ESFsmoothed=ESFsmoothed[j])

            if not(ESFideal is None):
                profileText += "\t{ESFideal}".format(ESFideal=ESFideal[j])

            profileText += "\t{LSF}\t{LSFwindowed}".format(LSF=LSF[j], LSFwindowed=LSFwindowed[j])

            if not(LSFideal is None):
                profileText += "\t{LSFideal}".format(LSFideal=LSFideal[j])

            profileText += "\n"


            mtfText += "{f:.3f}\t{mtf}".format(f=MTFpos[j], mtf=MTF[j])

            if not(MTFideal is None):
                mtfText += "\t{MTFideal}".format(MTFideal=MTFideal[j])


            mtfText += "\n"

        profileFileName = "{dir}/{name}_{subname}_ESF_LSF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(profileFileName, 'w') as profileFile:
            profileFile.write(profileText)
            profileFile.close()

        mtfFileName = "{dir}/{name}_{subname}_MTF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(mtfFileName, 'w') as mtfFile:
            mtfFile.write(mtfText)
            mtfFile.close()

    def run(self, image):
        self.prepare()
        self.prepareRun(self.currentRun)
        i = self.currentRun
        subtestName = self.subtests[i]

        print("Calculating the LSF and MTF of the projection image...")

        # Calculate the edge spread function (using a line profile across the edge):
        self.results[i].lineProfileGV, self.results[i].lineProfilePos, stepsize = image.lineProfile(x0=self.p0.x(), y0=self.p0.y(), x1=self.p1.x(), y1=self.p1.y(), width=self.profileWidth, resolution=self.profileRes)

        ESFmax = numpy.amax(self.results[i].lineProfileGV)
        if ESFmax != 0:
            self.results[i].lineProfileGV /= ESFmax

        # Nyquist frequency determines center of MTF frequency range:
        self.results[i].fnyq = 1.0 / (2.0*stepsize)
        nSamples = len(self.results[i].lineProfileGV)
        #self.results[i].MTFpos = numpy.linspace(start=0, stop=2*self.results[i].fnyq, num=nSamples, endpoint=False, dtype=numpy.float64)

        # Make the profile positions symmetric:
        self.results[i].lineProfilePos -= self.profileLength / 2

        # Calculate the line spread function and MTF.
        self.results[i].lineProfileSmoothed, self.results[i].LSF, self.results[i].LSF_windowed, self.results[i].MTF, self.results[i].MTFpos = MTF(positions=self.results[i].lineProfilePos, ESF=self.results[i].lineProfileGV)

        # Calculate the ideal and the measured MTF10 and MTF20 frequency:
        #self.results[i].MTF10freq_ideal    = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].ideal_MTF_ConvolutionFFT, modulation=0.1)
        #self.results[i].MTF10freq_measured = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].MTF, modulation=0.1)
        self.results[i].MTF20freq_ideal    = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].ideal_MTF_ConvolutionFFT, modulation=0.2)
        self.results[i].MTF20freq_measured = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].MTF, modulation=0.2)


        # Fit a Gaussian to central half of the data:
        sigma = self.results[i].nominalGaussianSigmaPX
        nBins = len(self.results[i].lineProfilePos)
        fitParametersInitial = (0, sigma, 1)
        fitPositions = self.results[i].lineProfilePos[int(nBins//4):int(3*nBins//4)]
        fitData = self.results[i].LSF[int(nBins//4):int(3*nBins//4)]
        popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

        perr = numpy.sqrt(numpy.diag(pcov))

        self.results[i].measuredGaussianSigmaPX      = popt[1]
        self.results[i].measuredGaussianSigmaPXerror = perr[1]
        self.results[i].measuredGaussianMuPX         = popt[0]
        self.results[i].measuredGaussianMuPXerror    = perr[0]
        self.results[i].measuredGaussianAmpPX        = popt[2]
        self.results[i].measuredGaussianAmpPXerror   = perr[2]

        self.results[i].measuredGaussianSigmaMM      = self.results[i].pixelSize * self.results[i].measuredGaussianSigmaPX
        self.results[i].measuredGaussianSigmaMMerror = self.results[i].pixelSize * self.results[i].measuredGaussianSigmaPXerror
        self.results[i].measuredGaussianMuMM         = self.results[i].pixelSize * self.results[i].measuredGaussianMuPX
        self.results[i].measuredGaussianMuMMerror    = self.results[i].pixelSize * self.results[i].measuredGaussianMuPXerror
        self.results[i].measuredGaussianAmpMM        = self.results[i].pixelSize * self.results[i].measuredGaussianAmpPX
        self.results[i].measuredGaussianAmpMMerror   = self.results[i].pixelSize * self.results[i].measuredGaussianAmpPXerror

        print("- Measured LSF Fit Mu:    {} +- {}".format(popt[0], perr[0]))
        print("- Measured LSF Fit Sigma: {} +- {}".format(popt[1], perr[1]))
        print("- Measured LSF Fit A:     {} +- {}".format(popt[2], perr[2]))

        # Rescale LSF to maximum of fit function:
        if self.results[i].measuredGaussianAmpPX != 0.0:
            self.results[i].LSF /= self.results[i].measuredGaussianAmpPX
            self.results[i].LSF_windowed /= self.results[i].measuredGaussianAmpPX
            self.results[i].measuredGaussianAmpPX = 1.0

        self.results[i].LSF_gaussian_fit = numpy.zeros_like(a=self.results[i].lineProfilePos, dtype=numpy.dtype('float64'))
        for j in range(len(self.results[i].LSF_gaussian_fit)):
            self.results[i].LSF_gaussian_fit[j] = gaussian(x=self.results[i].lineProfilePos[j], mu=self.results[i].measuredGaussianMuPX, sigma=self.results[i].measuredGaussianSigmaPX, A=self.results[i].measuredGaussianAmpPX)

        self.writeResultFile(subname=subtestName, results=self.results[i])
        self.writeSummaryFile(subname=subtestName, results=self.results[i])

        log("Evaluation data for test {name}, {subname} written to {dir}.".format(name=self.name, subname=subtestName, dir=self.resultFileDirectory))

        self.plotResults()

        self.currentRun += 1
        return image

    def followUp(self):
        pass

    def plotResults(self):
        i = self.currentRun
        subtestName = self.subtests[i]
        try:
            import matplotlib
            import matplotlib.pyplot
            from matplotlib.ticker import (MultipleLocator, FormatStrFormatter, AutoMinorLocator)

            matplotlib.use("agg")

            fig, (ax1, ax2, ax3) = matplotlib.pyplot.subplots(nrows=3, ncols=1, figsize=(6, 8))

            # ESF:
            ax1.plot(self.results[i].lineProfilePos, self.results[i].lineProfileGV, linewidth=1.5, label="Measured", color='#ffaa00')
            ax1.plot(self.results[i].lineProfilePos, self.results[i].ideal_ESF_Convolution, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            ax1.set_xlabel("Horizontal distance in px")
            ax1.set_ylabel("ESF")
            ax1.set_xlim([-3*self.results[i].nominalGaussianSigmaPX, 3*self.results[i].nominalGaussianSigmaPX])
            ax1.set_title("Edge Spread Function (ESF)")
            #ax1.xaxis.set_ticklabels([])
            ax1.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax1.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax1.legend(loc='best')


            # LSF
            ax2.plot(self.results[i].lineProfilePos, self.results[i].LSF, linewidth=1.5, label="Measured", color='#ffaa00')
            ax2.plot(self.results[i].lineProfilePos, self.results[i].LSF_gaussian_fit, linewidth=1.0, label="Fit", color='#1f77b4', linestyle='dashed')
            ax2.plot(self.results[i].lineProfilePos, self.results[i].ideal_LSF_Convolution, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            ax2.set_xlabel("Horizontal distance in px")
            ax2.set_ylabel("LSF")
            ax2.set_xlim([-3*self.results[i].nominalGaussianSigmaPX, 3*self.results[i].nominalGaussianSigmaPX])
            ax2.set_title("Line Spread Function (LSF)")
            #ax2.xaxis.set_ticklabels([])
            ax2.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax2.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax2.legend(loc='best')


            # Lines for MTF 10:
            #mtf10ideal_vert_x    = numpy.array([self.results[i].MTF10freq_ideal, self.results[i].MTF10freq_ideal])
            #mtf10measured_vert_x = numpy.array([self.results[i].MTF10freq_measured, self.results[i].MTF10freq_measured])
            #mtf10_vert_y = numpy.array([-0.1, 0.1])

            #mtf10_horz_x = numpy.array([0, 3.0*max(self.results[i].MTF10freq_measured, self.results[i].MTF10freq_ideal)])
            #mtf10_horz_y = numpy.array([0.1, 0.1])


            # Lines for MTF 20:
            mtf20ideal_vert_x    = numpy.array([self.results[i].MTF20freq_ideal, self.results[i].MTF20freq_ideal])
            mtf20measured_vert_x = numpy.array([self.results[i].MTF20freq_measured, self.results[i].MTF20freq_measured])
            mtf20_vert_y = numpy.array([-0.1, 0.2])

            mtf20_horz_x = numpy.array([0, 3.0*max(self.results[i].MTF20freq_measured, self.results[i].MTF20freq_ideal)])
            mtf20_horz_y = numpy.array([0.2, 0.2])

            # MTF
            ax3.plot(self.results[i].MTFpos, self.results[i].MTF, linewidth=1.5, label="Measured", color='#ffaa00')
            ax3.plot(self.results[i].MTFpos, self.results[i].ideal_MTF_ConvolutionFFT, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            #ax3.plot(mtf10_horz_x, mtf10_horz_y,         linewidth=0.5, color='#000000', label="MTF10")
            #ax3.plot(mtf10ideal_vert_x, mtf10_vert_y,    linewidth=0.5, color='#000000', label="")
            #ax3.plot(mtf10measured_vert_x, mtf10_vert_y, linewidth=0.5, color='#000000', label="")

            #ax3.plot(mtf20_horz_x, mtf20_horz_y,         linewidth=0.5, color='#808080', label="MTF20")
            ax3.plot(mtf20ideal_vert_x, mtf20_vert_y,    linewidth=0.5, color='#808080', label="", linestyle='dotted')
            ax3.plot(mtf20measured_vert_x, mtf20_vert_y, linewidth=0.5, color='#808080', label="")

            ax3.set_xlabel("Modulation frequency in cycles/px")
            ax3.set_ylabel("Modulation contrast")
            ax3.set_xlim([0, 3.0*max(self.results[i].MTF20freq_measured, self.results[i].MTF20freq_ideal)])
            ax3.set_ylim([-0.1, 1.1])
            ax3.set_yticks(numpy.array([0, 0.2, 0.4, 0.6, 0.8, 1]))
            ax3.set_title("Modulation Transfer Function (MTF)")
            #ax3.xaxis.set_ticklabels([])
            ax3.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax3.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax3.legend(loc='best')

            fig.tight_layout(pad=2.5)

            plotFilename = "{dir}/{name}_{subname}_ESF_LSF_MTF.png".format(dir=self.resultFileDirectory, name=self.name, subname=subtestName)
            matplotlib.pyplot.savefig(plotFilename)
            fig.clf()
            matplotlib.pyplot.close('all')
        except Exception as e:
            log(f"Warning: Error plotting results for test {self.name}, {subtestName} using matplotlib: {e}")

Classes

class Test2D_WE_1 (resultFileDirectory='.', name=None, rawOutput=False)

CTSimU test 2D-WE-1: focal spot size.

Expand source code
class Test2D_WE_1(generalTest):
    """ CTSimU test 2D-WE-1: focal spot size. """

    def __init__(self, resultFileDirectory=".", name=None, rawOutput=False):
        generalTest.__init__(
            self,
            testName="2D-WE-1",
            name=name,
            resultFileDirectory=resultFileDirectory,
            rawOutput=rawOutput)

        self.geometry = None
        self.analyticalIntensityProfileImage = None  # analytical flat field
        self.analyticalEdgeImage             = None  # stores the analytically computed edge image (sharp image)
        self.analyticalEdgeImageFF           = None  # stores the analytically computed edge image, flat-field corrected (sharp image)

        self.results = []
        self.results_idealPointSource = Test2D_WE_1_results()

        # Start point (x0,y0) and end point (x1,y1) for line profile:
        edgeAngle = 3 * (math.pi/180.0)    # 3 deg edge rotation

        # point of origin for edge profile (i.e. profile center):
        edgeOrigin = Vector(0, 1)                            # start with unit vector pointing "down"
        edgeOrigin.rotate_2D_xy(edgeAngle)                   # rotate by edge angle
        edgeOrigin.scale(500.5 / math.cos(edgeAngle) / 2.0)  # scale to half of visible edge length
        edgeOrigin = edgeOrigin + Vector(500.5, 500.5)       # move to detector center

        #print("Edge origin:")
        #print(edgeOrigin)

        self.profileLength = 100.1   # pixels

        edgeDirection = Vector(self.profileLength/2.0, 0)
        edgeDirection.rotate_2D_xy(edgeAngle)

        self.p0 = edgeOrigin - edgeDirection
        self.p1 = edgeOrigin + edgeDirection

        #print("Line From p0:")
        #print(self.p0)
        #print("Line To p1:")
        #print(self.p1)

        self.profileWidth = 200  # pixels
        self.profileRes   = 0.1  # pixels

        # Also, prepare the clipping rectangle for the analytical
        # calculation of the ideal edge image. In pixel coordinates.
        A = Vector(   0,    0, 0)
        B = Vector(   0,  300, 0)
        C = Vector(-300,  300, 0)
        D = Vector(-300,    0, 0)

        #A.rotate_2D_xy(edgeAngle)
        B.rotate_2D_xy(edgeAngle)
        C.rotate_2D_xy(edgeAngle)
        D.rotate_2D_xy(edgeAngle)

        self.clippingRectangle = Polygon(A, B, C, D)

    def prepare(self):
        """ Preparations before the test will be run with the images from the pipeline. """
        if not isinstance(self.pipe, Pipeline):
            self.prepared = False
            raise Exception("Step must be part of a processing pipeline before it can prepare. Current pipeline: {}".format(self.pipe))

        if len(self.subtests) > 0:
            if not self.prepared:
                # It doesn't matter which of the sub-scenarios we take here.
                # They all have the same geometry.
                self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"

                if(self.jsonScenarioFile is not None):
                    self.scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                    self.geometry = self.scenario.current_geometry()
                    self.geometry.update()

                    print("Computing an analytical image for an ideal point source...")
                    self.analyticalIntensityProfileImage, self.analyticalEdgeImage = self.geometry.create_detector_flat_field_sphere(self.clippingRectangle)
                    self.analyticalEdgeImageFF = copy.deepcopy(self.analyticalEdgeImage)
                    self.analyticalEdgeImageFF.applyFlatfield(ref=self.analyticalIntensityProfileImage, rescaleFactor=60000.0)

                    # Raise analytical images to maximum grey value of 60000 before saving them.
                    # This rescaling does not affect the previous FF correction.
                    self.analyticalIntensityProfileImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)
                    self.analyticalEdgeImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)


                    # Write analytical images:
                    if self.rawOutput:
                        self.analyticalIntensityProfileImage.saveRAW("{dir}/{name}_ideal_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                        self.analyticalEdgeImage.saveRAW("{dir}/{name}_ideal_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                        self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_ideal_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    else: # TIFF
                        self.analyticalIntensityProfileImage.save("{dir}/{name}_ideal_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                        self.analyticalEdgeImage.save("{dir}/{name}_ideal_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                        self.analyticalEdgeImageFF.save("{dir}/{name}_ideal_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")


                    print("Calculating the fundamental LSF and MTF from the ideal edge image...")
                    # Calculate the edge spread function (using a line profile across the edge):
                    self.results_idealPointSource.lineProfileGV, self.results_idealPointSource.lineProfilePos, stepsize = self.analyticalEdgeImageFF.lineProfile(x0=self.p0.x(), y0=self.p0.y(), x1=self.p1.x(), y1=self.p1.y(), width=self.profileWidth, resolution=self.profileRes)

                    # Nyquist frequency determines center of MTF frequency range:
                    self.results_idealPointSource.fnyq = 1.0 / (2.0*stepsize)
                    nSamples = len(self.results_idealPointSource.lineProfileGV)
                    #self.results_idealPointSource.MTFpos = numpy.linspace(start=0, stop=2*self.results_idealPointSource.fnyq, num=nSamples, endpoint=False, dtype=numpy.float64)

                    # Make the profile positions symmetric:
                    self.results_idealPointSource.lineProfilePos -= self.profileLength / 2

                    # Calculate the line spread function and MTF.
                    self.results_idealPointSource.lineProfileSmoothed, self.results_idealPointSource.LSF, self.results_idealPointSource.LSF_windowed, self.results_idealPointSource.MTF, self.results_idealPointSource.MTFpos = MTF(positions=self.results_idealPointSource.lineProfilePos, ESF=self.results_idealPointSource.lineProfileGV)

                    self.results_idealPointSource.lineProfileDelta = self.results_idealPointSource.lineProfileSmoothed - self.results_idealPointSource.lineProfileGV

                    self.writeResultFile(subname="fundamental_spotPoint", results=self.results_idealPointSource)

                    self.prepared = True
                else:
                    raise Exception("Test 2D-WE-1: Please provide a JSON scenario description.")

    def prepareRun(self, i):
        if i < len(self.subtests):
            self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
            if self.subtests[i] == "spot1":
                self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
            elif self.subtests[i] == "spot2":
                self.jsonScenarioFile = "2D-WE-1_Spot2_2021-10-13v02r04dp.json"
            else:
                raise Exception("{key} is not a valid subtest identifier for test scenario {test}".format(key=self.subtests[i], test=self.testName))

            results = Test2D_WE_1_results()

            # Get the Gaussian spot size and the pixel size from the JSON file:
            if self.jsonScenarioFile is not None:
                scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                results.nominalGaussianSigmaMM = scenario.source.spot.sigma.u.get()
                results.pixelSize = scenario.detector.pixel_pitch.u.get()

                results.nominalGaussianSigmaPX = results.nominalGaussianSigmaMM / results.pixelSize

            if self.subtests[i] == "spotPoint":
                results.nominalGaussianSigmaMM = 0
                results.nominalGaussianSigmaPX = 0
            else:
                # Create an ideal Gaussian LSF:
                nBins = len(self.results_idealPointSource.lineProfilePos)
                sigma = results.nominalGaussianSigmaPX
                results.ideal_LSF_Gaussian = numpy.zeros_like(a=self.results_idealPointSource.lineProfilePos, dtype=numpy.dtype('float64'))
                for j in range(nBins):
                    s = self.results_idealPointSource.lineProfilePos[j]
                    sLeft  = s - self.profileRes
                    sRight = s + self.profileRes
                    results.ideal_LSF_Gaussian[j] = math.erf(sRight/(math.sqrt(2.0)*sigma)) - math.erf(sLeft/(math.sqrt(2.0)*sigma)) #gaussian((s+sNext)/2.0, 0, sigma, 1)

                # Normalize ideal LSF maximum to 1:
                idealLSFmax = numpy.amax(results.ideal_LSF_Gaussian)
                if idealLSFmax != 0:
                    results.ideal_LSF_Gaussian /= idealLSFmax

                # Calculate convolution of ideal LSF of sharp image and Gaussian LSF:
                results.ideal_LSF_Convolution = numpy.convolve(a=self.results_idealPointSource.LSF, v=results.ideal_LSF_Gaussian, mode='same')
                convMax = numpy.amax(results.ideal_LSF_Convolution)
                if convMax != 0:
                    results.ideal_LSF_Convolution /= convMax

                # Calculate ideal ESF as convolution of Gaussian with the ideal sampledESF:
                results.ideal_ESF_Convolution = numpy.convolve(a=self.results_idealPointSource.lineProfileGV, v=results.ideal_LSF_Gaussian, mode='same')
                convMax = numpy.amax(results.ideal_ESF_Convolution)
                if convMax != 0:
                    results.ideal_ESF_Convolution /= convMax

                # Fit a Gaussian to central half of the data:
                fitParametersInitial = (0, sigma, 1)
                fitPositions = self.results_idealPointSource.lineProfilePos[int(nBins//4):int(3*nBins//4)]
                #fitData = results.ideal_LSF_Gaussian #[int(nBins//4):int(3*nBins//4)]
                #popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

                #print("- Ideal Gaussian Fit Mu:    {}".format(popt[0]))
                #print("- Ideal Gaussian Fit Sigma: {}".format(popt[1]))
                #print("- Ideal Gaussian Fit A:     {}".format(popt[2]))

                fitData = results.ideal_LSF_Convolution[int(nBins//4):int(3*nBins//4)]
                popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

                perr = numpy.sqrt(numpy.diag(pcov))

                results.idealGaussianSigmaPX      = popt[1]
                results.idealGaussianSigmaPXerror = perr[1]
                results.idealGaussianMuPX         = popt[0]
                results.idealGaussianMuPXerror    = perr[0]
                results.idealGaussianAmpPX        = popt[2]
                results.idealGaussianAmpPXerror   = perr[2]

                results.idealGaussianSigmaMM      = results.pixelSize * results.idealGaussianSigmaPX
                results.idealGaussianSigmaMMerror = results.pixelSize * results.idealGaussianSigmaPXerror
                results.idealGaussianMuMM         = results.pixelSize * results.idealGaussianMuPX
                results.idealGaussianMuMMerror    = results.pixelSize * results.idealGaussianMuPXerror
                results.idealGaussianAmpMM        = results.pixelSize * results.idealGaussianAmpPX
                results.idealGaussianAmpMMerror   = results.pixelSize * results.idealGaussianAmpPXerror

                print("- Analytical LSF Fit Mu:    {} +- {}".format(popt[0], perr[0]))
                print("- Analytical LSF Fit Sigma: {} +- {}".format(popt[1], perr[1]))
                print("- Analytical LSF Fit A:     {} +- {}".format(popt[2], perr[2]))


                # Calculate MTF for Gaussian LSF and Convolution LSF:
                smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_Gaussian, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Gaussian)

                smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_ConvolutionFFT, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Convolution)

                results.ideal_MTF_Multiplication = self.results_idealPointSource.MTF * results.ideal_MTF_Gaussian
                if results.ideal_MTF_Multiplication[0] != 0:
                    results.ideal_MTF_Multiplication /= results.ideal_MTF_Multiplication[0]


            print("Gaussian Spot Size: {} mm = {} px".format(results.nominalGaussianSigmaMM, results.nominalGaussianSigmaPX))

            self.results.append(results)
        else:
            if len(self.subtests) == 0:
                raise Exception("Please provide keywords that identify which metadata file belongs to which subtest. Test {testname} accepts the following keywords: 'spot1' and 'spot2'.".format(testname=self.testName))
            else:
                raise Exception("Number of provided image metadata files exceeds number of test runs ({expected}).".format(expected=len(self.subtests)))

    def writeSummaryFile(self, subname, results):
        summaryText  = "Evaluation results for {testname} {subtest}\n".format(testname=self.testName, subtest=subname)
        summaryText += "=================================================\n\n"

        summaryText += "Fit results for gauss(x) = A * exp(-(x-mu)²/(2*sigma²))\n"
        summaryText += "Errors: 1 standard deviation\n"
        summaryText += "-------------------------------------------------------\n"
        summaryText += "measured:   sigma [px] = {} +- {}\n".format(results.measuredGaussianSigmaPX, results.measuredGaussianSigmaPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianSigmaMM, results.measuredGaussianSigmaMMerror)
        summaryText += "measured:   mu    [px] = {} +- {}\n".format(results.measuredGaussianMuPX,    results.measuredGaussianMuPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianMuMM,    results.measuredGaussianMuMMerror)
        summaryText += "measured:   A     [px] = {} +- {}\n".format(results.measuredGaussianAmpPX,   results.measuredGaussianAmpPXerror)
        summaryText += "                  [mm] = {} +- {}\n\n".format(results.measuredGaussianAmpMM, results.measuredGaussianAmpMMerror)

        summaryText += "analytical: sigma [px] = {} +- {}\n".format(results.idealGaussianSigmaPX, results.idealGaussianSigmaPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianSigmaMM, results.idealGaussianSigmaMMerror)
        summaryText += "analytical: mu    [px] = {} +- {}\n".format(results.idealGaussianMuPX,    results.idealGaussianMuPXerror)
        summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianMuMM,    results.idealGaussianMuMMerror)
        summaryText += "analytical: A     [px] = {} +- {}\n".format(results.idealGaussianAmpPX,   results.idealGaussianAmpPXerror)
        summaryText += "                  [mm] = {} +- {}\n\n".format(results.idealGaussianAmpMM, results.idealGaussianAmpMMerror)

        summaryText += "sigma: abs. deviation (measured-analytical) [px]       = {:.5f}\n".format(results.sigma_absDev())
        summaryText += "sigma: abs. deviation (measured-analytical) [mm]       = {:.5f}\n".format(results.sigma_absDev() * results.pixelSize)
        summaryText += "sigma: rel. deviation (measured-analytical)/analytical = {:.5f}\n\n".format(results.sigma_relDev())


        #summaryText += "MTF 10% frequency\n"
        #summaryText += "-------------------------------------------------------\n"
        #summaryText += "MTF10 measured: [cycles/px] = {:.3f}\n".format(results.MTF10freq_measured)
        #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_measured / results.pixelSize)

        #summaryText += "MTF10 ideal:    [cycles/px] = {:.3f}\n".format(results.MTF10freq_ideal)
        #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_ideal / results.pixelSize)

        #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/px] = {:.5f}\n".format(results.MTF10freq_absDev())
        #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/mm] = {:.5f}\n".format(results.MTF10freq_absDev() / results.pixelSize)
        #summaryText += "MTF10: rel. deviation (measured-ideal)/ideal       = {:.5f}\n".format(results.MTF10freq_relDev())


        summaryText += "\nMTF 20% frequency\n"
        summaryText += "-------------------------------------------------------\n"
        summaryText += "MTF20 measured:   [cycles/px] = {:.5f}\n".format(results.MTF20freq_measured)
        summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_measured / results.pixelSize)

        summaryText += "MTF20 analytical: [cycles/px] = {:.5f}\n".format(results.MTF20freq_ideal)
        summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_ideal / results.pixelSize)

        summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/px] = {:.5f}\n".format(results.MTF20freq_absDev())
        summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/mm] = {:.5f}\n".format(results.MTF20freq_absDev() / results.pixelSize)
        summaryText += "MTF20: rel. deviation (measured-analytical)/analytical  = {:.5f}\n".format(results.MTF20freq_relDev())


        summaryFileName = "{dir}/{name}_{subname}_summary.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(summaryFileName, 'w') as summaryFile:
            summaryFile.write(summaryText)
            summaryFile.close()


    def writeResultFile(self, subname, results):
        pos = results.lineProfilePos

        # ESF
        ESF         = results.lineProfileGV
        ESFsmoothed = results.lineProfileSmoothed
        ESFideal    = results.ideal_ESF_Convolution

        # LSF
        LSF         = results.LSF
        LSFwindowed = results.LSF_windowed
        LSFideal    = results.ideal_LSF_Convolution

        # MTF
        MTFpos      = results.MTFpos
        MTF         = results.MTF
        MTFideal    = results.ideal_MTF_ConvolutionFFT

        profileText  = "# Profile data: edge spread function (ESF), smoothed ESF, line spread function (LSF) and windowed LSF (von-Hann window)\n"
        profileText += "# s [px]\tESF\tESF_smoothed"
        if not(ESFideal is None):
            profileText += "\tESF_ideal"

        profileText += "\tLSF\tLSF_windowed"

        if not(LSFideal is None):
            profileText += "\tLSF_ideal"

        profileText += "\n"

        mtfText  = "# Modulation Transfer Function (MTF)\n"
        mtfText += "# f [cycles/px]\tMTF_measured"
        if not(MTFideal is None):
            mtfText += "\tMTF_ideal"

        mtfText += "\n"

        for j in range(len(pos)):
            profileText += "{pos:.2f}\t{ESF}\t{ESFsmoothed}".format(pos=pos[j], ESF=ESF[j], ESFsmoothed=ESFsmoothed[j])

            if not(ESFideal is None):
                profileText += "\t{ESFideal}".format(ESFideal=ESFideal[j])

            profileText += "\t{LSF}\t{LSFwindowed}".format(LSF=LSF[j], LSFwindowed=LSFwindowed[j])

            if not(LSFideal is None):
                profileText += "\t{LSFideal}".format(LSFideal=LSFideal[j])

            profileText += "\n"


            mtfText += "{f:.3f}\t{mtf}".format(f=MTFpos[j], mtf=MTF[j])

            if not(MTFideal is None):
                mtfText += "\t{MTFideal}".format(MTFideal=MTFideal[j])


            mtfText += "\n"

        profileFileName = "{dir}/{name}_{subname}_ESF_LSF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(profileFileName, 'w') as profileFile:
            profileFile.write(profileText)
            profileFile.close()

        mtfFileName = "{dir}/{name}_{subname}_MTF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
        with open(mtfFileName, 'w') as mtfFile:
            mtfFile.write(mtfText)
            mtfFile.close()

    def run(self, image):
        self.prepare()
        self.prepareRun(self.currentRun)
        i = self.currentRun
        subtestName = self.subtests[i]

        print("Calculating the LSF and MTF of the projection image...")

        # Calculate the edge spread function (using a line profile across the edge):
        self.results[i].lineProfileGV, self.results[i].lineProfilePos, stepsize = image.lineProfile(x0=self.p0.x(), y0=self.p0.y(), x1=self.p1.x(), y1=self.p1.y(), width=self.profileWidth, resolution=self.profileRes)

        ESFmax = numpy.amax(self.results[i].lineProfileGV)
        if ESFmax != 0:
            self.results[i].lineProfileGV /= ESFmax

        # Nyquist frequency determines center of MTF frequency range:
        self.results[i].fnyq = 1.0 / (2.0*stepsize)
        nSamples = len(self.results[i].lineProfileGV)
        #self.results[i].MTFpos = numpy.linspace(start=0, stop=2*self.results[i].fnyq, num=nSamples, endpoint=False, dtype=numpy.float64)

        # Make the profile positions symmetric:
        self.results[i].lineProfilePos -= self.profileLength / 2

        # Calculate the line spread function and MTF.
        self.results[i].lineProfileSmoothed, self.results[i].LSF, self.results[i].LSF_windowed, self.results[i].MTF, self.results[i].MTFpos = MTF(positions=self.results[i].lineProfilePos, ESF=self.results[i].lineProfileGV)

        # Calculate the ideal and the measured MTF10 and MTF20 frequency:
        #self.results[i].MTF10freq_ideal    = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].ideal_MTF_ConvolutionFFT, modulation=0.1)
        #self.results[i].MTF10freq_measured = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].MTF, modulation=0.1)
        self.results[i].MTF20freq_ideal    = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].ideal_MTF_ConvolutionFFT, modulation=0.2)
        self.results[i].MTF20freq_measured = MTFfreq(MTFpos=self.results[i].MTFpos, MTF=self.results[i].MTF, modulation=0.2)


        # Fit a Gaussian to central half of the data:
        sigma = self.results[i].nominalGaussianSigmaPX
        nBins = len(self.results[i].lineProfilePos)
        fitParametersInitial = (0, sigma, 1)
        fitPositions = self.results[i].lineProfilePos[int(nBins//4):int(3*nBins//4)]
        fitData = self.results[i].LSF[int(nBins//4):int(3*nBins//4)]
        popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

        perr = numpy.sqrt(numpy.diag(pcov))

        self.results[i].measuredGaussianSigmaPX      = popt[1]
        self.results[i].measuredGaussianSigmaPXerror = perr[1]
        self.results[i].measuredGaussianMuPX         = popt[0]
        self.results[i].measuredGaussianMuPXerror    = perr[0]
        self.results[i].measuredGaussianAmpPX        = popt[2]
        self.results[i].measuredGaussianAmpPXerror   = perr[2]

        self.results[i].measuredGaussianSigmaMM      = self.results[i].pixelSize * self.results[i].measuredGaussianSigmaPX
        self.results[i].measuredGaussianSigmaMMerror = self.results[i].pixelSize * self.results[i].measuredGaussianSigmaPXerror
        self.results[i].measuredGaussianMuMM         = self.results[i].pixelSize * self.results[i].measuredGaussianMuPX
        self.results[i].measuredGaussianMuMMerror    = self.results[i].pixelSize * self.results[i].measuredGaussianMuPXerror
        self.results[i].measuredGaussianAmpMM        = self.results[i].pixelSize * self.results[i].measuredGaussianAmpPX
        self.results[i].measuredGaussianAmpMMerror   = self.results[i].pixelSize * self.results[i].measuredGaussianAmpPXerror

        print("- Measured LSF Fit Mu:    {} +- {}".format(popt[0], perr[0]))
        print("- Measured LSF Fit Sigma: {} +- {}".format(popt[1], perr[1]))
        print("- Measured LSF Fit A:     {} +- {}".format(popt[2], perr[2]))

        # Rescale LSF to maximum of fit function:
        if self.results[i].measuredGaussianAmpPX != 0.0:
            self.results[i].LSF /= self.results[i].measuredGaussianAmpPX
            self.results[i].LSF_windowed /= self.results[i].measuredGaussianAmpPX
            self.results[i].measuredGaussianAmpPX = 1.0

        self.results[i].LSF_gaussian_fit = numpy.zeros_like(a=self.results[i].lineProfilePos, dtype=numpy.dtype('float64'))
        for j in range(len(self.results[i].LSF_gaussian_fit)):
            self.results[i].LSF_gaussian_fit[j] = gaussian(x=self.results[i].lineProfilePos[j], mu=self.results[i].measuredGaussianMuPX, sigma=self.results[i].measuredGaussianSigmaPX, A=self.results[i].measuredGaussianAmpPX)

        self.writeResultFile(subname=subtestName, results=self.results[i])
        self.writeSummaryFile(subname=subtestName, results=self.results[i])

        log("Evaluation data for test {name}, {subname} written to {dir}.".format(name=self.name, subname=subtestName, dir=self.resultFileDirectory))

        self.plotResults()

        self.currentRun += 1
        return image

    def followUp(self):
        pass

    def plotResults(self):
        i = self.currentRun
        subtestName = self.subtests[i]
        try:
            import matplotlib
            import matplotlib.pyplot
            from matplotlib.ticker import (MultipleLocator, FormatStrFormatter, AutoMinorLocator)

            matplotlib.use("agg")

            fig, (ax1, ax2, ax3) = matplotlib.pyplot.subplots(nrows=3, ncols=1, figsize=(6, 8))

            # ESF:
            ax1.plot(self.results[i].lineProfilePos, self.results[i].lineProfileGV, linewidth=1.5, label="Measured", color='#ffaa00')
            ax1.plot(self.results[i].lineProfilePos, self.results[i].ideal_ESF_Convolution, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            ax1.set_xlabel("Horizontal distance in px")
            ax1.set_ylabel("ESF")
            ax1.set_xlim([-3*self.results[i].nominalGaussianSigmaPX, 3*self.results[i].nominalGaussianSigmaPX])
            ax1.set_title("Edge Spread Function (ESF)")
            #ax1.xaxis.set_ticklabels([])
            ax1.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax1.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax1.legend(loc='best')


            # LSF
            ax2.plot(self.results[i].lineProfilePos, self.results[i].LSF, linewidth=1.5, label="Measured", color='#ffaa00')
            ax2.plot(self.results[i].lineProfilePos, self.results[i].LSF_gaussian_fit, linewidth=1.0, label="Fit", color='#1f77b4', linestyle='dashed')
            ax2.plot(self.results[i].lineProfilePos, self.results[i].ideal_LSF_Convolution, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            ax2.set_xlabel("Horizontal distance in px")
            ax2.set_ylabel("LSF")
            ax2.set_xlim([-3*self.results[i].nominalGaussianSigmaPX, 3*self.results[i].nominalGaussianSigmaPX])
            ax2.set_title("Line Spread Function (LSF)")
            #ax2.xaxis.set_ticklabels([])
            ax2.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax2.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax2.legend(loc='best')


            # Lines for MTF 10:
            #mtf10ideal_vert_x    = numpy.array([self.results[i].MTF10freq_ideal, self.results[i].MTF10freq_ideal])
            #mtf10measured_vert_x = numpy.array([self.results[i].MTF10freq_measured, self.results[i].MTF10freq_measured])
            #mtf10_vert_y = numpy.array([-0.1, 0.1])

            #mtf10_horz_x = numpy.array([0, 3.0*max(self.results[i].MTF10freq_measured, self.results[i].MTF10freq_ideal)])
            #mtf10_horz_y = numpy.array([0.1, 0.1])


            # Lines for MTF 20:
            mtf20ideal_vert_x    = numpy.array([self.results[i].MTF20freq_ideal, self.results[i].MTF20freq_ideal])
            mtf20measured_vert_x = numpy.array([self.results[i].MTF20freq_measured, self.results[i].MTF20freq_measured])
            mtf20_vert_y = numpy.array([-0.1, 0.2])

            mtf20_horz_x = numpy.array([0, 3.0*max(self.results[i].MTF20freq_measured, self.results[i].MTF20freq_ideal)])
            mtf20_horz_y = numpy.array([0.2, 0.2])

            # MTF
            ax3.plot(self.results[i].MTFpos, self.results[i].MTF, linewidth=1.5, label="Measured", color='#ffaa00')
            ax3.plot(self.results[i].MTFpos, self.results[i].ideal_MTF_ConvolutionFFT, linewidth=1.0, label="Analytical", color='#000000', linestyle='dotted')

            #ax3.plot(mtf10_horz_x, mtf10_horz_y,         linewidth=0.5, color='#000000', label="MTF10")
            #ax3.plot(mtf10ideal_vert_x, mtf10_vert_y,    linewidth=0.5, color='#000000', label="")
            #ax3.plot(mtf10measured_vert_x, mtf10_vert_y, linewidth=0.5, color='#000000', label="")

            #ax3.plot(mtf20_horz_x, mtf20_horz_y,         linewidth=0.5, color='#808080', label="MTF20")
            ax3.plot(mtf20ideal_vert_x, mtf20_vert_y,    linewidth=0.5, color='#808080', label="", linestyle='dotted')
            ax3.plot(mtf20measured_vert_x, mtf20_vert_y, linewidth=0.5, color='#808080', label="")

            ax3.set_xlabel("Modulation frequency in cycles/px")
            ax3.set_ylabel("Modulation contrast")
            ax3.set_xlim([0, 3.0*max(self.results[i].MTF20freq_measured, self.results[i].MTF20freq_ideal)])
            ax3.set_ylim([-0.1, 1.1])
            ax3.set_yticks(numpy.array([0, 0.2, 0.4, 0.6, 0.8, 1]))
            ax3.set_title("Modulation Transfer Function (MTF)")
            #ax3.xaxis.set_ticklabels([])
            ax3.grid(visible=True, which='major', axis='both', color='#d9d9d9', linestyle='dashed')
            ax3.grid(visible=True, which='minor', axis='both', color='#e7e7e7', linestyle='dotted')
            ax3.legend(loc='best')

            fig.tight_layout(pad=2.5)

            plotFilename = "{dir}/{name}_{subname}_ESF_LSF_MTF.png".format(dir=self.resultFileDirectory, name=self.name, subname=subtestName)
            matplotlib.pyplot.savefig(plotFilename)
            fig.clf()
            matplotlib.pyplot.close('all')
        except Exception as e:
            log(f"Warning: Error plotting results for test {self.name}, {subtestName} using matplotlib: {e}")

Ancestors

Methods

def prepare(self)

Preparations before the test will be run with the images from the pipeline.

Expand source code
def prepare(self):
    """ Preparations before the test will be run with the images from the pipeline. """
    if not isinstance(self.pipe, Pipeline):
        self.prepared = False
        raise Exception("Step must be part of a processing pipeline before it can prepare. Current pipeline: {}".format(self.pipe))

    if len(self.subtests) > 0:
        if not self.prepared:
            # It doesn't matter which of the sub-scenarios we take here.
            # They all have the same geometry.
            self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"

            if(self.jsonScenarioFile is not None):
                self.scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                self.geometry = self.scenario.current_geometry()
                self.geometry.update()

                print("Computing an analytical image for an ideal point source...")
                self.analyticalIntensityProfileImage, self.analyticalEdgeImage = self.geometry.create_detector_flat_field_sphere(self.clippingRectangle)
                self.analyticalEdgeImageFF = copy.deepcopy(self.analyticalEdgeImage)
                self.analyticalEdgeImageFF.applyFlatfield(ref=self.analyticalIntensityProfileImage, rescaleFactor=60000.0)

                # Raise analytical images to maximum grey value of 60000 before saving them.
                # This rescaling does not affect the previous FF correction.
                self.analyticalIntensityProfileImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)
                self.analyticalEdgeImage.renormalize(newMin=0.0, newMax=60000.0, currentMin=0.0, currentMax=1.0)


                # Write analytical images:
                if self.rawOutput:
                    self.analyticalIntensityProfileImage.saveRAW("{dir}/{name}_ideal_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImage.saveRAW("{dir}/{name}_ideal_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_ideal_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                else: # TIFF
                    self.analyticalIntensityProfileImage.save("{dir}/{name}_ideal_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImage.save("{dir}/{name}_ideal_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImageFF.save("{dir}/{name}_ideal_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")


                print("Calculating the fundamental LSF and MTF from the ideal edge image...")
                # Calculate the edge spread function (using a line profile across the edge):
                self.results_idealPointSource.lineProfileGV, self.results_idealPointSource.lineProfilePos, stepsize = self.analyticalEdgeImageFF.lineProfile(x0=self.p0.x(), y0=self.p0.y(), x1=self.p1.x(), y1=self.p1.y(), width=self.profileWidth, resolution=self.profileRes)

                # Nyquist frequency determines center of MTF frequency range:
                self.results_idealPointSource.fnyq = 1.0 / (2.0*stepsize)
                nSamples = len(self.results_idealPointSource.lineProfileGV)
                #self.results_idealPointSource.MTFpos = numpy.linspace(start=0, stop=2*self.results_idealPointSource.fnyq, num=nSamples, endpoint=False, dtype=numpy.float64)

                # Make the profile positions symmetric:
                self.results_idealPointSource.lineProfilePos -= self.profileLength / 2

                # Calculate the line spread function and MTF.
                self.results_idealPointSource.lineProfileSmoothed, self.results_idealPointSource.LSF, self.results_idealPointSource.LSF_windowed, self.results_idealPointSource.MTF, self.results_idealPointSource.MTFpos = MTF(positions=self.results_idealPointSource.lineProfilePos, ESF=self.results_idealPointSource.lineProfileGV)

                self.results_idealPointSource.lineProfileDelta = self.results_idealPointSource.lineProfileSmoothed - self.results_idealPointSource.lineProfileGV

                self.writeResultFile(subname="fundamental_spotPoint", results=self.results_idealPointSource)

                self.prepared = True
            else:
                raise Exception("Test 2D-WE-1: Please provide a JSON scenario description.")
def prepareRun(self, i)
Expand source code
def prepareRun(self, i):
    if i < len(self.subtests):
        self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
        if self.subtests[i] == "spot1":
            self.jsonScenarioFile = "2D-WE-1_Spot1_2021-10-13v02r04dp.json"
        elif self.subtests[i] == "spot2":
            self.jsonScenarioFile = "2D-WE-1_Spot2_2021-10-13v02r04dp.json"
        else:
            raise Exception("{key} is not a valid subtest identifier for test scenario {test}".format(key=self.subtests[i], test=self.testName))

        results = Test2D_WE_1_results()

        # Get the Gaussian spot size and the pixel size from the JSON file:
        if self.jsonScenarioFile is not None:
            scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

            results.nominalGaussianSigmaMM = scenario.source.spot.sigma.u.get()
            results.pixelSize = scenario.detector.pixel_pitch.u.get()

            results.nominalGaussianSigmaPX = results.nominalGaussianSigmaMM / results.pixelSize

        if self.subtests[i] == "spotPoint":
            results.nominalGaussianSigmaMM = 0
            results.nominalGaussianSigmaPX = 0
        else:
            # Create an ideal Gaussian LSF:
            nBins = len(self.results_idealPointSource.lineProfilePos)
            sigma = results.nominalGaussianSigmaPX
            results.ideal_LSF_Gaussian = numpy.zeros_like(a=self.results_idealPointSource.lineProfilePos, dtype=numpy.dtype('float64'))
            for j in range(nBins):
                s = self.results_idealPointSource.lineProfilePos[j]
                sLeft  = s - self.profileRes
                sRight = s + self.profileRes
                results.ideal_LSF_Gaussian[j] = math.erf(sRight/(math.sqrt(2.0)*sigma)) - math.erf(sLeft/(math.sqrt(2.0)*sigma)) #gaussian((s+sNext)/2.0, 0, sigma, 1)

            # Normalize ideal LSF maximum to 1:
            idealLSFmax = numpy.amax(results.ideal_LSF_Gaussian)
            if idealLSFmax != 0:
                results.ideal_LSF_Gaussian /= idealLSFmax

            # Calculate convolution of ideal LSF of sharp image and Gaussian LSF:
            results.ideal_LSF_Convolution = numpy.convolve(a=self.results_idealPointSource.LSF, v=results.ideal_LSF_Gaussian, mode='same')
            convMax = numpy.amax(results.ideal_LSF_Convolution)
            if convMax != 0:
                results.ideal_LSF_Convolution /= convMax

            # Calculate ideal ESF as convolution of Gaussian with the ideal sampledESF:
            results.ideal_ESF_Convolution = numpy.convolve(a=self.results_idealPointSource.lineProfileGV, v=results.ideal_LSF_Gaussian, mode='same')
            convMax = numpy.amax(results.ideal_ESF_Convolution)
            if convMax != 0:
                results.ideal_ESF_Convolution /= convMax

            # Fit a Gaussian to central half of the data:
            fitParametersInitial = (0, sigma, 1)
            fitPositions = self.results_idealPointSource.lineProfilePos[int(nBins//4):int(3*nBins//4)]
            #fitData = results.ideal_LSF_Gaussian #[int(nBins//4):int(3*nBins//4)]
            #popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

            #print("- Ideal Gaussian Fit Mu:    {}".format(popt[0]))
            #print("- Ideal Gaussian Fit Sigma: {}".format(popt[1]))
            #print("- Ideal Gaussian Fit A:     {}".format(popt[2]))

            fitData = results.ideal_LSF_Convolution[int(nBins//4):int(3*nBins//4)]
            popt, pcov = optimize.curve_fit(f=gaussian, xdata=fitPositions, ydata=fitData, p0=fitParametersInitial)

            perr = numpy.sqrt(numpy.diag(pcov))

            results.idealGaussianSigmaPX      = popt[1]
            results.idealGaussianSigmaPXerror = perr[1]
            results.idealGaussianMuPX         = popt[0]
            results.idealGaussianMuPXerror    = perr[0]
            results.idealGaussianAmpPX        = popt[2]
            results.idealGaussianAmpPXerror   = perr[2]

            results.idealGaussianSigmaMM      = results.pixelSize * results.idealGaussianSigmaPX
            results.idealGaussianSigmaMMerror = results.pixelSize * results.idealGaussianSigmaPXerror
            results.idealGaussianMuMM         = results.pixelSize * results.idealGaussianMuPX
            results.idealGaussianMuMMerror    = results.pixelSize * results.idealGaussianMuPXerror
            results.idealGaussianAmpMM        = results.pixelSize * results.idealGaussianAmpPX
            results.idealGaussianAmpMMerror   = results.pixelSize * results.idealGaussianAmpPXerror

            print("- Analytical LSF Fit Mu:    {} +- {}".format(popt[0], perr[0]))
            print("- Analytical LSF Fit Sigma: {} +- {}".format(popt[1], perr[1]))
            print("- Analytical LSF Fit A:     {} +- {}".format(popt[2], perr[2]))


            # Calculate MTF for Gaussian LSF and Convolution LSF:
            smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_Gaussian, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Gaussian)

            smoothedESF, retLSF, retLSFsmoothed, results.ideal_MTF_ConvolutionFFT, pos = MTF(positions=self.results_idealPointSource.lineProfilePos, LSF=results.ideal_LSF_Convolution)

            results.ideal_MTF_Multiplication = self.results_idealPointSource.MTF * results.ideal_MTF_Gaussian
            if results.ideal_MTF_Multiplication[0] != 0:
                results.ideal_MTF_Multiplication /= results.ideal_MTF_Multiplication[0]


        print("Gaussian Spot Size: {} mm = {} px".format(results.nominalGaussianSigmaMM, results.nominalGaussianSigmaPX))

        self.results.append(results)
    else:
        if len(self.subtests) == 0:
            raise Exception("Please provide keywords that identify which metadata file belongs to which subtest. Test {testname} accepts the following keywords: 'spot1' and 'spot2'.".format(testname=self.testName))
        else:
            raise Exception("Number of provided image metadata files exceeds number of test runs ({expected}).".format(expected=len(self.subtests)))
def writeResultFile(self, subname, results)
Expand source code
def writeResultFile(self, subname, results):
    pos = results.lineProfilePos

    # ESF
    ESF         = results.lineProfileGV
    ESFsmoothed = results.lineProfileSmoothed
    ESFideal    = results.ideal_ESF_Convolution

    # LSF
    LSF         = results.LSF
    LSFwindowed = results.LSF_windowed
    LSFideal    = results.ideal_LSF_Convolution

    # MTF
    MTFpos      = results.MTFpos
    MTF         = results.MTF
    MTFideal    = results.ideal_MTF_ConvolutionFFT

    profileText  = "# Profile data: edge spread function (ESF), smoothed ESF, line spread function (LSF) and windowed LSF (von-Hann window)\n"
    profileText += "# s [px]\tESF\tESF_smoothed"
    if not(ESFideal is None):
        profileText += "\tESF_ideal"

    profileText += "\tLSF\tLSF_windowed"

    if not(LSFideal is None):
        profileText += "\tLSF_ideal"

    profileText += "\n"

    mtfText  = "# Modulation Transfer Function (MTF)\n"
    mtfText += "# f [cycles/px]\tMTF_measured"
    if not(MTFideal is None):
        mtfText += "\tMTF_ideal"

    mtfText += "\n"

    for j in range(len(pos)):
        profileText += "{pos:.2f}\t{ESF}\t{ESFsmoothed}".format(pos=pos[j], ESF=ESF[j], ESFsmoothed=ESFsmoothed[j])

        if not(ESFideal is None):
            profileText += "\t{ESFideal}".format(ESFideal=ESFideal[j])

        profileText += "\t{LSF}\t{LSFwindowed}".format(LSF=LSF[j], LSFwindowed=LSFwindowed[j])

        if not(LSFideal is None):
            profileText += "\t{LSFideal}".format(LSFideal=LSFideal[j])

        profileText += "\n"


        mtfText += "{f:.3f}\t{mtf}".format(f=MTFpos[j], mtf=MTF[j])

        if not(MTFideal is None):
            mtfText += "\t{MTFideal}".format(MTFideal=MTFideal[j])


        mtfText += "\n"

    profileFileName = "{dir}/{name}_{subname}_ESF_LSF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
    with open(profileFileName, 'w') as profileFile:
        profileFile.write(profileText)
        profileFile.close()

    mtfFileName = "{dir}/{name}_{subname}_MTF.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
    with open(mtfFileName, 'w') as mtfFile:
        mtfFile.write(mtfText)
        mtfFile.close()
def writeSummaryFile(self, subname, results)
Expand source code
def writeSummaryFile(self, subname, results):
    summaryText  = "Evaluation results for {testname} {subtest}\n".format(testname=self.testName, subtest=subname)
    summaryText += "=================================================\n\n"

    summaryText += "Fit results for gauss(x) = A * exp(-(x-mu)²/(2*sigma²))\n"
    summaryText += "Errors: 1 standard deviation\n"
    summaryText += "-------------------------------------------------------\n"
    summaryText += "measured:   sigma [px] = {} +- {}\n".format(results.measuredGaussianSigmaPX, results.measuredGaussianSigmaPXerror)
    summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianSigmaMM, results.measuredGaussianSigmaMMerror)
    summaryText += "measured:   mu    [px] = {} +- {}\n".format(results.measuredGaussianMuPX,    results.measuredGaussianMuPXerror)
    summaryText += "                  [mm] = {} +- {}\n".format(results.measuredGaussianMuMM,    results.measuredGaussianMuMMerror)
    summaryText += "measured:   A     [px] = {} +- {}\n".format(results.measuredGaussianAmpPX,   results.measuredGaussianAmpPXerror)
    summaryText += "                  [mm] = {} +- {}\n\n".format(results.measuredGaussianAmpMM, results.measuredGaussianAmpMMerror)

    summaryText += "analytical: sigma [px] = {} +- {}\n".format(results.idealGaussianSigmaPX, results.idealGaussianSigmaPXerror)
    summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianSigmaMM, results.idealGaussianSigmaMMerror)
    summaryText += "analytical: mu    [px] = {} +- {}\n".format(results.idealGaussianMuPX,    results.idealGaussianMuPXerror)
    summaryText += "                  [mm] = {} +- {}\n".format(results.idealGaussianMuMM,    results.idealGaussianMuMMerror)
    summaryText += "analytical: A     [px] = {} +- {}\n".format(results.idealGaussianAmpPX,   results.idealGaussianAmpPXerror)
    summaryText += "                  [mm] = {} +- {}\n\n".format(results.idealGaussianAmpMM, results.idealGaussianAmpMMerror)

    summaryText += "sigma: abs. deviation (measured-analytical) [px]       = {:.5f}\n".format(results.sigma_absDev())
    summaryText += "sigma: abs. deviation (measured-analytical) [mm]       = {:.5f}\n".format(results.sigma_absDev() * results.pixelSize)
    summaryText += "sigma: rel. deviation (measured-analytical)/analytical = {:.5f}\n\n".format(results.sigma_relDev())


    #summaryText += "MTF 10% frequency\n"
    #summaryText += "-------------------------------------------------------\n"
    #summaryText += "MTF10 measured: [cycles/px] = {:.3f}\n".format(results.MTF10freq_measured)
    #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_measured / results.pixelSize)

    #summaryText += "MTF10 ideal:    [cycles/px] = {:.3f}\n".format(results.MTF10freq_ideal)
    #summaryText += "                [cycles/mm] = {:.3f}\n\n".format(results.MTF10freq_ideal / results.pixelSize)

    #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/px] = {:.5f}\n".format(results.MTF10freq_absDev())
    #summaryText += "MTF10: abs. deviation (measured-ideal) [cycles/mm] = {:.5f}\n".format(results.MTF10freq_absDev() / results.pixelSize)
    #summaryText += "MTF10: rel. deviation (measured-ideal)/ideal       = {:.5f}\n".format(results.MTF10freq_relDev())


    summaryText += "\nMTF 20% frequency\n"
    summaryText += "-------------------------------------------------------\n"
    summaryText += "MTF20 measured:   [cycles/px] = {:.5f}\n".format(results.MTF20freq_measured)
    summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_measured / results.pixelSize)

    summaryText += "MTF20 analytical: [cycles/px] = {:.5f}\n".format(results.MTF20freq_ideal)
    summaryText += "                  [cycles/mm] = {:.5f}\n\n".format(results.MTF20freq_ideal / results.pixelSize)

    summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/px] = {:.5f}\n".format(results.MTF20freq_absDev())
    summaryText += "MTF20: abs. deviation (measured-analytical) [cycles/mm] = {:.5f}\n".format(results.MTF20freq_absDev() / results.pixelSize)
    summaryText += "MTF20: rel. deviation (measured-analytical)/analytical  = {:.5f}\n".format(results.MTF20freq_relDev())


    summaryFileName = "{dir}/{name}_{subname}_summary.txt".format(dir=self.resultFileDirectory, name=self.name, subname=subname)
    with open(summaryFileName, 'w') as summaryFile:
        summaryFile.write(summaryText)
        summaryFile.close()

Inherited members

class Test2D_WE_1_results

Results for one sub test.

Expand source code
class Test2D_WE_1_results:
    """ Results for one sub test. """

    def __init__(self):
        # Profile Data:
        self.lineProfilePos      = []
        self.lineProfileGV       = []  # this will be the edge spread function (ESF)
        self.lineProfileSmoothed = []
        self.LSF                 = []  # line spread function; derivative of ESF.
        self.LSF_windowed        = []  # von-Hann window applied before FFT
        self.LSF_gaussian_fit    = []
        self.MTF                 = []
        self.MTFpos              = []
        self.fnyq                = 0   # Nyquist frequency

        self.ideal_ESF_Convolution = None      # Convolution of Gaussian with SampledESF

        self.ideal_LSF_Gaussian    = None
        self.ideal_LSF_Convolution = None

        self.ideal_MTF_Gaussian       = None   # MTF of Gaussian LSF
        self.ideal_MTF_ConvolutionFFT = None   # Ideal MTF by FFT of Convolution (Gaussian and SampledLSF)
        self.ideal_MTF_Multiplication = None   # Ideal MTF from SampledMTF * GaussianMTF; same result.

        # Fit results [px]
        self.nominalGaussianSigmaPX       = 0

        self.idealGaussianSigmaPX         = 0
        self.idealGaussianSigmaPXerror    = 0
        self.idealGaussianMuPX            = 0
        self.idealGaussianMuPXerror       = 0
        self.idealGaussianAmpPX           = 0
        self.idealGaussianAmpPXerror      = 0

        self.measuredGaussianSigmaPX      = 0
        self.measuredGaussianSigmaPXerror = 0
        self.measuredGaussianMuPX         = 0
        self.measuredGaussianMuPXerror    = 0
        self.measuredGaussianAmpPX        = 0
        self.measuredGaussianAmpPXerror   = 0

        # Fit results [mm]
        self.nominalGaussianSigmaMM       = 0

        self.idealGaussianSigmaMM         = 0
        self.idealGaussianSigmaMMerror    = 0
        self.idealGaussianMuMM            = 0
        self.idealGaussianMuMMerror       = 0
        self.idealGaussianAmpMM           = 0
        self.idealGaussianAmpMMerror      = 0

        self.measuredGaussianSigmaMM      = 0
        self.measuredGaussianSigmaMMerror = 0
        self.measuredGaussianMuMM         = 0
        self.measuredGaussianMuMMerror    = 0
        self.measuredGaussianAmpMM        = 0
        self.measuredGaussianAmpMMerror   = 0

        # MTF evaluation results
        self.MTF20freq_ideal    = 0
        self.MTF20freq_measured = 0

        #self.MTF10freq_ideal    = 0
        #self.MTF10freq_measured = 0

        self.pixelSize = 0

    def MTF20freq_absDev(self):
        return (self.MTF20freq_measured - self.MTF20freq_ideal)

    def MTF20freq_relDev(self):
        if self.MTF20freq_ideal != 0:
            return (self.MTF20freq_measured - self.MTF20freq_ideal) / self.MTF20freq_ideal

        return 0

    #def MTF10freq_absDev(self):
    #    return (self.MTF10freq_measured - self.MTF10freq_ideal)

    #def MTF10freq_relDev(self):
    #    if self.MTF10freq_ideal != 0:
    #        return (self.MTF10freq_measured - self.MTF10freq_ideal) / self.MTF10freq_ideal

        return 0

    def sigma_absDev(self):
        return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX)

    def sigma_relDev(self):
        if self.idealGaussianSigmaPX != 0:
            return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX) / self.idealGaussianSigmaPX

        return 0

Methods

def MTF20freq_absDev(self)
Expand source code
def MTF20freq_absDev(self):
    return (self.MTF20freq_measured - self.MTF20freq_ideal)
def MTF20freq_relDev(self)
Expand source code
def MTF20freq_relDev(self):
    if self.MTF20freq_ideal != 0:
        return (self.MTF20freq_measured - self.MTF20freq_ideal) / self.MTF20freq_ideal

    return 0

#def MTF10freq_absDev(self):
#    return (self.MTF10freq_measured - self.MTF10freq_ideal)

#def MTF10freq_relDev(self):
#    if self.MTF10freq_ideal != 0:
#        return (self.MTF10freq_measured - self.MTF10freq_ideal) / self.MTF10freq_ideal

    return 0
def sigma_absDev(self)
Expand source code
def sigma_absDev(self):
    return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX)
def sigma_relDev(self)
Expand source code
def sigma_relDev(self):
    if self.idealGaussianSigmaPX != 0:
        return (self.measuredGaussianSigmaPX - self.idealGaussianSigmaPX) / self.idealGaussianSigmaPX

    return 0