Module ctsimu.evaluation.test2D_WE_2

Test 2D-WE-2: Effect of partial pixel coverage

If a detector pixel is partly covered by an ideal absorber, its gray value should (approximately) scale inversely proportionally with the area fraction that is covered. This effect is tested here using a very thin (0.01 mm) edge that is made of a high-density material. Its upper right corner is placed at the center of the detector's central pixel, and then tilted around this point by 3°, as illustrated in the figure below.

To run the evaluation for this test, simply pass its identifier and the name of your metadata file (for projection and flat field) to the toolbox:

from ctsimu.toolbox import Toolbox
Toolbox("2D-WE-2", "2D-WE-2_metadata.json")

Partial pixel coverage

To calculate the ideal projection image of an ideal edge, we use the spherical trigonometry approach (see internal toolbox documentation: "How it works under the hood") to obtain an analytical value for each pixel's intensity. For pixels which are fully exposed or fully covered, this is straightforward. Pixels that are partly covered must be separated into an exposed region and a covered region. To treat the covered regions, we regard each pixel as a square polygon in the detector plane. Each of these polygons is then clipped using the shadow of the edge as a clipping polygon. By employing the Sutherland-Hodgman polygon clipping algorithm we obtain a clipped polygon for which we can calculate the solid angle in the same way as we do for a full pixel. The edges of these clipped polygons are illustrated by dotted red lines in the figure above.

The intensity of a pixel is therefore proportional to the solid angle of the uncovered area of the full pixel polygon:

I(\text{pixel}) \sim \Omega(\text{full pixel}) - \Omega(\text{clipped pixel}).

The toolbox regards any pixel with a gray value above zero and below 60000 as partly covered. For the analytical image, it identifies the set A of partly covered pixels, and for the provided projection image to be evaluated, it identifies a set M of partly covered pixels.

The following results are then calculated.

  • A difference image, showing the gray value difference \Delta = \text{GV}_\text{measured}-\text{GV}_\text{analytical} for each pixel.
  • The number of partly covered pixels in the analytical image, \left|A\right|=1053, and in the projection image to be evaluated, \left|M\right|.
  • The number of pixels that are partly covered in both images: \left|A \cap M\right|, i.e., the number of pixels in the intersection of both sets. This number should match \left|M\right| if the edge is positioned correctly. If \left|M\right| is higher, this means that partly covered pixels have been identified in the image to be evaluated which should be either fully exposed or fully covered.
  • The ratio r of the number of pixels that are covered in both vs. the number of pixels covered in the analytical image: r = \frac{\left|A \cap M\right|}{\left|A\right|} Ideally, this ratio is 1 (i.e., 100%). However, it only makes a statement about the extent to which M covers the ideal set A, and does not take into account any pixels that might be incorrectly partly covered outside of the region of analytically partly-covered pixels.
  • The root mean square difference between the analytical and the measured image for all pixels in the analytically ideal set A, i.e., for all pixels that are partly covered in the analytical edge image: \text{RMSD} = \sqrt{\frac{1}{\left|A\right|} \sum_{p \in A} \left[ I_\text{measured}(p) - I_\text{analytical}(p) \right]^2 }.
  • A list of the coordinates of partly covered pixels in the analytical edge image (i.e., all pixels from set \left|A\right|, their gray value in the analytical and in the measured image, and their respective difference.
Expand source code
# -*- coding: UTF-8 -*-
"""# Test 2D-WE-2: Effect of partial pixel coverage

.. include:: ./test2D_WE_2.md
"""

from ..test import *
from ..helpers import *
from ..primitives import Vector, Polygon
from ..scenario import Scenario

class Test2D_WE_2(generalTest):
    """ CTSimU test 2D-WE-2: Effect of partial pixel coverage. """

    def __init__(self, resultFileDirectory=".", name=None, rawOutput=False):

        generalTest.__init__(
            self,
            testName="2D-WE-2",
            name=name,
            nExpectedRuns=1,
            resultFileDirectory=resultFileDirectory,
            rawOutput=rawOutput)
        
        self.geometry = None
        self.analyticalIntensityProfileImage = None  # analytical flat field
        self.analyticalEdgeImage             = None  # stores the analytically computed edge image
        self.analyticalEdgeImageFF           = None  # stores the analytically computed edge image, flat-field corrected

        self.differenceImage                 = None  # difference between analytical edge image and provided image

        self.numberOfPartlyCoveredPixels_analytical = 0
        self.numberOfPartlyCoveredPixels_measured   = 0
        self.matchingPixels                         = 0  # number of partly covered pixels with matching positions
        self.partlyCovered_rmsd                     = 0  # RMSD of pixels in difference image that are partly covered in analytical image

        # 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)

        edgeAngle = 3 * (math.pi/180.0)  # 3 deg edge rotation

        #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 not self.prepared:
            self.jsonScenarioFile = "2D-WE-2_2021-10-13v02r03dp.json"

            # Calculate the analytical image of an edge covering the image:
            if(self.jsonScenarioFile is not None):
                scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                self.geometry = scenario.current_geometry()
                self.geometry.update()
                
                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}_analytical_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImage.saveRAW("{dir}/{name}_analytical_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_analytical_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                else: # TIFF
                    self.analyticalIntensityProfileImage.save("{dir}/{name}_analytical_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImage.save("{dir}/{name}_analytical_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImageFF.save("{dir}/{name}_analytical_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")

                self.numberOfPartlyCoveredPixels_analytical = numpy.count_nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))

                print("Number of partly covered pixels in analytical image: {}".format(self.numberOfPartlyCoveredPixels_analytical))

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


    def run(self, image):
        self.prepare()
        self.currentRun += 1

        self.numberOfPartlyCoveredPixels_measured = numpy.count_nonzero((image.px > 0) & (image.px < 60000))

        print("Number of partly covered pixels in measured image:   {}".format(self.numberOfPartlyCoveredPixels_measured))

        # Match in covered pixels:
        self.matchingPixels = numpy.count_nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000) & (image.px > 0) & (image.px < 60000))

        print("Number of pixels partly covered in both images:      {}".format(self.matchingPixels))

        self.matchRatio = self.matchingPixels / self.numberOfPartlyCoveredPixels_analytical

        print("Ratio (covered in both)/(covered in analytical):     {:.2f}%".format(100.0*self.matchRatio))


        self.differenceImage = copy.deepcopy(image)
        self.differenceImage.subtractImage(self.analyticalEdgeImageFF)    

        if self.rawOutput:
            self.differenceImage.saveRAW("{dir}/{name}_difference.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
        else: # TIFF
            self.differenceImage.save("{dir}/{name}_difference.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")


        self.differenceImage.square()
        self.partlyCovered_rmsd = math.sqrt(numpy.mean(self.differenceImage.px[numpy.nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))]))

        print("RMSD of partly covered (analytical) pixels:          {:.2f} GV".format(self.partlyCovered_rmsd))



        # Write a CSV file with all pixel values and their differences
        csvText = "# x [px]\ty [px]\tAnalytical GV\tMeasured GV\tDifference\n"

        partlyCoveredCoordinates_analytical = numpy.nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))

        for i in range(len(partlyCoveredCoordinates_analytical[0])):
            x = partlyCoveredCoordinates_analytical[1][i]
            y = partlyCoveredCoordinates_analytical[0][i]
            analytical = self.analyticalEdgeImageFF.px[y][x]
            measured   = image.px[y][x]
            difference = measured - analytical

            csvText += "{x}\t{y}\t{analytical:.3f}\t{measured:.3f}\t{delta:.3f}\n".format(x=x, y=y, analytical=analytical, measured=measured, delta=difference)

        csvFileName = "{dir}/{name}_pixel_list.txt".format(dir=self.resultFileDirectory, name=self.name)
        with open(csvFileName, 'w') as csvFile:
            csvFile.write(csvText)
            csvFile.close()

        self.plotResults()

        return image

    def plotResults(self):
        pass

    def followUp(self):
        log("Writing evaluation results...")

        summaryText  = "# |A| = Number of partly covered pixels in analytical image: {}\n".format(self.numberOfPartlyCoveredPixels_analytical)
        summaryText += "# |M| = Number of partly covered pixels in measured image:   {}\n".format(self.numberOfPartlyCoveredPixels_measured)
        summaryText += "# Number of pixels partly covered in both images:            {}\n".format(self.matchingPixels)
        summaryText += "# r = Ratio (covered in both)/(covered in analytical):       {:.2f}%\n".format(100.0*self.matchRatio)
        summaryText += "# RMSD [partly covered analytical vs. measured]:             {:.2f} GV".format(self.partlyCovered_rmsd)

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

Classes

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

CTSimU test 2D-WE-2: Effect of partial pixel coverage.

Expand source code
class Test2D_WE_2(generalTest):
    """ CTSimU test 2D-WE-2: Effect of partial pixel coverage. """

    def __init__(self, resultFileDirectory=".", name=None, rawOutput=False):

        generalTest.__init__(
            self,
            testName="2D-WE-2",
            name=name,
            nExpectedRuns=1,
            resultFileDirectory=resultFileDirectory,
            rawOutput=rawOutput)
        
        self.geometry = None
        self.analyticalIntensityProfileImage = None  # analytical flat field
        self.analyticalEdgeImage             = None  # stores the analytically computed edge image
        self.analyticalEdgeImageFF           = None  # stores the analytically computed edge image, flat-field corrected

        self.differenceImage                 = None  # difference between analytical edge image and provided image

        self.numberOfPartlyCoveredPixels_analytical = 0
        self.numberOfPartlyCoveredPixels_measured   = 0
        self.matchingPixels                         = 0  # number of partly covered pixels with matching positions
        self.partlyCovered_rmsd                     = 0  # RMSD of pixels in difference image that are partly covered in analytical image

        # 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)

        edgeAngle = 3 * (math.pi/180.0)  # 3 deg edge rotation

        #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 not self.prepared:
            self.jsonScenarioFile = "2D-WE-2_2021-10-13v02r03dp.json"

            # Calculate the analytical image of an edge covering the image:
            if(self.jsonScenarioFile is not None):
                scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

                self.geometry = scenario.current_geometry()
                self.geometry.update()
                
                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}_analytical_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImage.saveRAW("{dir}/{name}_analytical_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                    self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_analytical_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                else: # TIFF
                    self.analyticalIntensityProfileImage.save("{dir}/{name}_analytical_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImage.save("{dir}/{name}_analytical_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                    self.analyticalEdgeImageFF.save("{dir}/{name}_analytical_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")

                self.numberOfPartlyCoveredPixels_analytical = numpy.count_nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))

                print("Number of partly covered pixels in analytical image: {}".format(self.numberOfPartlyCoveredPixels_analytical))

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


    def run(self, image):
        self.prepare()
        self.currentRun += 1

        self.numberOfPartlyCoveredPixels_measured = numpy.count_nonzero((image.px > 0) & (image.px < 60000))

        print("Number of partly covered pixels in measured image:   {}".format(self.numberOfPartlyCoveredPixels_measured))

        # Match in covered pixels:
        self.matchingPixels = numpy.count_nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000) & (image.px > 0) & (image.px < 60000))

        print("Number of pixels partly covered in both images:      {}".format(self.matchingPixels))

        self.matchRatio = self.matchingPixels / self.numberOfPartlyCoveredPixels_analytical

        print("Ratio (covered in both)/(covered in analytical):     {:.2f}%".format(100.0*self.matchRatio))


        self.differenceImage = copy.deepcopy(image)
        self.differenceImage.subtractImage(self.analyticalEdgeImageFF)    

        if self.rawOutput:
            self.differenceImage.saveRAW("{dir}/{name}_difference.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
        else: # TIFF
            self.differenceImage.save("{dir}/{name}_difference.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")


        self.differenceImage.square()
        self.partlyCovered_rmsd = math.sqrt(numpy.mean(self.differenceImage.px[numpy.nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))]))

        print("RMSD of partly covered (analytical) pixels:          {:.2f} GV".format(self.partlyCovered_rmsd))



        # Write a CSV file with all pixel values and their differences
        csvText = "# x [px]\ty [px]\tAnalytical GV\tMeasured GV\tDifference\n"

        partlyCoveredCoordinates_analytical = numpy.nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))

        for i in range(len(partlyCoveredCoordinates_analytical[0])):
            x = partlyCoveredCoordinates_analytical[1][i]
            y = partlyCoveredCoordinates_analytical[0][i]
            analytical = self.analyticalEdgeImageFF.px[y][x]
            measured   = image.px[y][x]
            difference = measured - analytical

            csvText += "{x}\t{y}\t{analytical:.3f}\t{measured:.3f}\t{delta:.3f}\n".format(x=x, y=y, analytical=analytical, measured=measured, delta=difference)

        csvFileName = "{dir}/{name}_pixel_list.txt".format(dir=self.resultFileDirectory, name=self.name)
        with open(csvFileName, 'w') as csvFile:
            csvFile.write(csvText)
            csvFile.close()

        self.plotResults()

        return image

    def plotResults(self):
        pass

    def followUp(self):
        log("Writing evaluation results...")

        summaryText  = "# |A| = Number of partly covered pixels in analytical image: {}\n".format(self.numberOfPartlyCoveredPixels_analytical)
        summaryText += "# |M| = Number of partly covered pixels in measured image:   {}\n".format(self.numberOfPartlyCoveredPixels_measured)
        summaryText += "# Number of pixels partly covered in both images:            {}\n".format(self.matchingPixels)
        summaryText += "# r = Ratio (covered in both)/(covered in analytical):       {:.2f}%\n".format(100.0*self.matchRatio)
        summaryText += "# RMSD [partly covered analytical vs. measured]:             {:.2f} GV".format(self.partlyCovered_rmsd)

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

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 not self.prepared:
        self.jsonScenarioFile = "2D-WE-2_2021-10-13v02r03dp.json"

        # Calculate the analytical image of an edge covering the image:
        if(self.jsonScenarioFile is not None):
            scenario = Scenario(json_dict=json_from_pkg(pkg_scenario(self.jsonScenarioFile)))

            self.geometry = scenario.current_geometry()
            self.geometry.update()
            
            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}_analytical_flat.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                self.analyticalEdgeImage.saveRAW("{dir}/{name}_analytical_edge.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
                self.analyticalEdgeImageFF.saveRAW("{dir}/{name}_analytical_edge_corrected.raw".format(dir=self.resultFileDirectory, name=self.name), dataType="float32", addInfo=True)
            else: # TIFF
                self.analyticalIntensityProfileImage.save("{dir}/{name}_analytical_flat.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                self.analyticalEdgeImage.save("{dir}/{name}_analytical_edge.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")
                self.analyticalEdgeImageFF.save("{dir}/{name}_analytical_edge_corrected.tif".format(dir=self.resultFileDirectory, name=self.name), dataType="float32")

            self.numberOfPartlyCoveredPixels_analytical = numpy.count_nonzero((self.analyticalEdgeImageFF.px > 0) & (self.analyticalEdgeImageFF.px < 60000))

            print("Number of partly covered pixels in analytical image: {}".format(self.numberOfPartlyCoveredPixels_analytical))

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

Inherited members