Exemplo n.º 1
0
    def test_multiples(self):
        paths = [dcm_path, dcm_path, dcm_path]
        img = Image.load_multiples(paths)
        self.assertIsInstance(img, DicomImage)

        # test non-superimposable images
        paths = [dcm_path, tif_path]
        with self.assertRaises(ValueError):
            Image.load_multiples(paths)
Exemplo n.º 2
0
    def test_multiples(self):
        paths = [dcm_path, dcm_path, dcm_path]
        img = Image.load_multiples(paths)
        self.assertIsInstance(img, DicomImage)

        # test non-superimposable images
        paths = [dcm_path, tif_path]
        with self.assertRaises(ValueError):
            Image.load_multiples(paths)
Exemplo n.º 3
0
    def test_combine_multiples(self):
        bad_img_path = [dcm_path, img_path]
        self.assertRaises(AttributeError, Image.from_multiples, bad_img_path)

        good_img_path = [img_path, img_path]
        combined_img = Image.from_multiples(good_img_path)
        self.assertIsInstance(combined_img, Image)
Exemplo n.º 4
0
    def _find_HU_slice(self):
        """Using a brute force search of the images, find the median HU linearity slice.

        This method walks through all the images and takes a collapsed circle profile where the HU
        linearity ROIs are. If the profile contains both low (<800) and high (>800) HU values and most values are the same
        (i.e. its not an artifact, then
        it can be assumed it is an HU linearity slice. The median of all applicable slices is the
        center of the HU slice.

        Returns
        -------
        int
            The middle slice of the HU linearity module.
        """
        hu_slices = []
        for image_number in range(self.num_images):
            image = self.images[:, :, image_number]
            slice = Slice(self, Image.from_array(image))
            try:
                slice.find_phan_center()
            except ValueError:  # a slice without the phantom in view
                continue
            else:
                circle_prof = CollapsedCircleProfile(slice.phan_center, radius=120/self.fov_ratio)
                circle_prof.get_profile(image, width_ratio=0.05, num_profiles=5)
                prof = circle_prof.y_values
                # determine if the profile contains both low and high values and that most values are the same
                if (np.percentile(prof, 2) < 800) and (np.percentile(prof, 98) > 800) and (np.percentile(prof, 80) - np.percentile(prof, 30) < 40):
                    hu_slices.append(image_number)

        center_hu_slice = int(np.median(hu_slices))
        return center_hu_slice
Exemplo n.º 5
0
    def load_image(self, file_path, im_type=None):
        """Load the image directly by the file path.

        Parameters
        ----------
        file_path : str, file-like object
            The path to the DICOM image or I/O stream.
        im_type : {'open', 'mlcs', None}
            Specifies what image type is being loaded in. If None, will try to determine the type from the name.
            The name must have 'open' or 'dmlc' in the name.
        """
        img = Image.load(file_path)
        if im_type is not None:
            if _is_open_type(im_type):
                self.image_open = img
            elif _is_dmlc_type(im_type):
                self.image_dmlc = img
        else:
            # try to guess type by the name
            imtype = self._try_to_guess_image_type(osp.basename(file_path))
            if imtype is not None:
                self.load_image(file_path, imtype)
            else:
                raise ValueError(
                    "Image type was not given nor could it be determined from the path name. Please enter and image type."
                )
Exemplo n.º 6
0
 def test_all(self):
     futures = []
     start = time.time()
     with concurrent.futures.ProcessPoolExecutor() as exec:
         for pdir, sdir, files in os.walk(self.image_bank_dir):
             for file in files:
                 filepath = osp.join(pdir, file)
                 try:
                     Image.load(filepath)
                 except:
                     pass
                 else:
                     future = exec.submit(run_star, filepath)
                     futures.append(future)
         for future in concurrent.futures.as_completed(futures):
             print(future.result())
     end = time.time() - start
     print('Processing of {} files took {}s'.format(len(futures), end))
Exemplo n.º 7
0
 def test_all(self):
     futures = []
     start = time.time()
     with concurrent.futures.ProcessPoolExecutor() as exec:
         for pdir, sdir, files in os.walk(self.image_bank_dir):
             for file in files:
                 filepath = osp.join(pdir, file)
                 try:
                     Image.load(filepath)
                 except:
                     pass
                 else:
                     future = exec.submit(run_star, filepath)
                     futures.append(future)
         for future in concurrent.futures.as_completed(futures):
             print(future.result())
     end = time.time() - start
     print('Processing of {} files took {}s'.format(len(futures), end))
Exemplo n.º 8
0
    def load_image(self, filepath):
        """Load the image via the file path.

        Parameters
        ----------
        filepath : str
            Path to the file to be loaded.
        """
        self.image = Image(filepath)
Exemplo n.º 9
0
    def load_image(self, filepath):
        """Load the image via the file path.

        Parameters
        ----------
        filepath : str
            Path to the file to be loaded.
        """
        self.image = Image.load(filepath)
Exemplo n.º 10
0
    def _find_bb(self):
        """Find the BB within the radiation field. Iteratively searches for a circle-like object
        by lowering a low-pass threshold value until found.

        Returns
        -------
        Point
            The weighted-pixel value location of the BB.
        """

        def is_boxlike(array):
            """Whether the binary object's dimensions are symmetric, i.e. box-like"""
            ymin, ymax, xmin, xmax = get_bounding_box(array)
            y = abs(ymax - ymin)
            x = abs(xmax - xmin)
            if x > max(y * 1.05, y+3) or x < min(y * 0.95, y-3):
                return False
            return True

        # get initial starting conditions
        hmin = np.percentile(self.array, 5)
        hmax = self.array.max()
        spread = hmax - hmin
        max_thresh = hmax

        # search for the BB by iteratively lowering the low-pass threshold value until the BB is found.
        found = False
        while not found:
            try:
                lower_thresh = hmax - spread / 2
                t = np.where((max_thresh > self) & (self >= lower_thresh), 1, 0)
                labeled_arr, num_roi = ndimage.measurements.label(t)
                roi_sizes, bin_edges = np.histogram(labeled_arr, bins=num_roi + 1)
                bw_node_cleaned = np.where(labeled_arr == np.argsort(roi_sizes)[-3], 1, 0)
                expected_fill_ratio = np.pi / 4
                actual_fill_ratio = get_filled_area_ratio(bw_node_cleaned)
                if (expected_fill_ratio * 1.1 < actual_fill_ratio) or (actual_fill_ratio < expected_fill_ratio * 0.9):
                    raise ValueError
                if not is_boxlike(bw_node_cleaned):
                    raise ValueError
            except (IndexError, ValueError):
                max_thresh -= 0.05 * spread
                if max_thresh < hmin:
                    raise ValueError("Unable to locate the BB")
            else:
                found = True

        # determine the center of mass of the BB
        inv_img = Image.load(self.array)
        inv_img.invert()
        x_arr = np.abs(np.average(bw_node_cleaned, weights=inv_img, axis=0))
        x_com = SingleProfile(x_arr).fwxm_center(interpolate=True)
        y_arr = np.abs(np.average(bw_node_cleaned, weights=inv_img, axis=1))
        y_com = SingleProfile(y_arr).fwxm_center(interpolate=True)
        return Point(x_com, y_com)
Exemplo n.º 11
0
    def load_multiple_images(self, filepath_list):
        """Load multiple images via the file path.

        .. versionadded:: 0.5.1

        Parameters
        ----------
        filepath_list : sequence
            An iterable sequence of filepath locations.
        """
        self.image = Image.load_multiples(filepath_list)
Exemplo n.º 12
0
    def __init__(self, settings):
        super().__init__(settings)
        self.scale_by_FOV()
        self.image = Image.from_array(combine_surrounding_slices(self.settings.images, self.settings.SR_slice_num, mode='max'))

        self.LP_MTF = OrderedDict()  # holds lp:mtf data
        for idx, radius in enumerate(self.radius2profs):
            c = SR_Circle_ROI(idx, self.image.array, radius=radius)
            self.add_ROI(c)

        super().find_phan_center()
Exemplo n.º 13
0
    def load_multiple_images(self, filepath_list):
        """Load multiple images via the file path.

        .. versionadded:: 0.5.1

        Parameters
        ----------
        filepath_list : sequence
            An iterable sequence of filepath locations.
        """
        self.image = Image.from_multiples(filepath_list)
Exemplo n.º 14
0
 def __init__(self, filepath=None):
     """
     Parameters
     ----------
     filepath : None, str
         If None, image must be loaded later.
         If a str, path to the image file.
     """
     if filepath is not None and is_valid_file(filepath):
         self.image = Image.load(filepath)
     else:
         self.image = np.zeros((1,1))
Exemplo n.º 15
0
    def load_image(self, filepath):
        """Load the image via the file path.

        Parameters
        ----------
        filepath : str
            Path to the file to be loaded.
        """
        self.image = Image(filepath)
        # apply filter if it's a large image to reduce noise
        if self.image.shape[0] > 1100:
            self.image.median_filter(0.002)
Exemplo n.º 16
0
    def load_multiple_images(self, path_list):
        """Load and superimpose multiple images.

        .. versionadded:: 0.9

        Parameters
        ----------
        path_list : iterable
            An iterable of path locations to the files to be loaded/combined.
        """
        self.image = Image.load_multiples(path_list, method='mean')
        self._check_for_noise()
        self.image.check_inversion()
Exemplo n.º 17
0
    def load_multiple_images(self, path_list):
        """Load and superimpose multiple images.

        .. versionadded:: 0.9

        Parameters
        ----------
        path_list : iterable
            An iterable of path locations to the files to be loaded/combined.
        """
        self.image = Image.load_multiples(path_list, method='mean')
        self._check_for_noise()
        self.image.check_inversion()
Exemplo n.º 18
0
    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image(file_path)
        if isinstance(filter, int):
            self.image.array = spfilt.median_filter(self.image.array, size=filter)
        self._clear_attrs()
Exemplo n.º 19
0
    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image.load(file_path)
        if isinstance(filter, int):
            self.image.median_filter(size=filter)
        self._check_for_noise()
        self.image.check_inversion()
Exemplo n.º 20
0
    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image(file_path)
        if isinstance(filter, int):
            self.image.median_filter(size=filter)
        self._check_for_noise()
        self.image.check_inversion()
Exemplo n.º 21
0
    def __init__(self, settings):
        super().__init__(settings)
        self.scale_by_FOV()
        self.image = Image.from_array(combine_surrounding_slices(self.settings.images, self.settings.UN_slice_num))

        HU_ROIp = partial(HU_ROI, slice_array=self.image.array, tolerance=settings.hu_tolerance, radius=self.obj_radius,
                          dist_from_center=self.dist2objs)

        # center has distance of 0, thus doesn't use partial
        center = HU_ROI('Center', 0, 0, self.image.array, self.obj_radius, dist_from_center=0, tolerance=settings.hu_tolerance)
        right = HU_ROIp('Right', 0, 0)
        top = HU_ROIp('Top', -90, 0)
        left = HU_ROIp('Left', 180, 0)
        bottom = HU_ROIp('Bottom', 90, 0)
        self.add_ROI(center, right, top, left, bottom)

        super().find_phan_center()
Exemplo n.º 22
0
    def __init__(self, settings):
        super().__init__(settings)
        self.scale_by_FOV()
        self.image = Image.from_array(combine_surrounding_slices(self.settings.images, self.settings.HU_slice_num))

        HU_ROIp = partial(HU_ROI, slice_array=self.image.array, radius=self.object_radius, dist_from_center=self.dist2objs,
                          tolerance=settings.hu_tolerance)

        air = HU_ROIp('Air', 90, -1000)
        pmp = HU_ROIp('PMP', 120, -200)
        ldpe = HU_ROIp('LDPE', 180, -100)
        poly = HU_ROIp('Poly', -120, -35)
        acrylic = HU_ROIp('Acrylic', -60, 120)
        delrin = HU_ROIp('Delrin', 0, 340)
        teflon = HU_ROIp('Teflon', 60, 990)
        self.add_ROI(air, pmp, ldpe, poly, acrylic, delrin, teflon)

        super().find_phan_center()
Exemplo n.º 23
0
    def test_open(self):
        """Test the open class method."""
        # load a tif file
        img = Image(img_path)
        self.assertEqual(img.im_type, IMAGE)

        # load a dicom file
        img2 = Image(dcm_path)
        self.assertEqual(img2.im_type, DICOM)

        # try loading a bad file
        bad_file = osp.abspath(__file__)
        self.assertRaises(TypeError, Image, bad_file)

        # not a valid parameter
        bad_input = 3.5
        self.assertRaises(TypeError, Image, bad_input)

        # load an array
        dcm = dicom.read_file(dcm_path)
        img = Image.from_array(dcm.pixel_array)
        self.assertEqual(img.im_type, ARRAY)
Exemplo n.º 24
0
    def __init__(self, settings):
        super().__init__(settings)
        self.tolerance = settings.scaling_tolerance
        self.scale_by_FOV()
        self.image = Image.from_array(combine_surrounding_slices(self.settings.images, self.settings.HU_slice_num, mode='median'))

        GEO_ROIp = partial(GEO_ROI, slice_array=self.image.array, radius=self.obj_radius,
                           dist_from_center=self.dist2objs)

        tl = GEO_ROIp(name='Top-Left', angle=-135)
        tr = GEO_ROIp(name='Top-Right', angle=-45)
        br = GEO_ROIp(name='Bottom-Right', angle=45)
        bl = GEO_ROIp(name='Bottom-Left', angle=135)
        self.add_ROI(tl, tr, br, bl)

        # Construct the Lines, mapping to the nodes they connect to
        lv = GEO_Line('Left-Vert', tl, bl)
        bh = GEO_Line('Bottom-Horiz', bl, br)
        rv = GEO_Line('Right-Vert', tr, br)
        th = GEO_Line('Top-Horiz', tl, tr)
        self.add_line(lv, bh, rv, th)

        super().find_phan_center()
Exemplo n.º 25
0
    def load_image(self, file_path, im_type=None):
        """Load the image directly by the file path.

        Parameters
        ----------
        file_path : str, file-like object
            The path to the DICOM image or I/O stream.
        im_type : {'open', 'mlcs', None}
            Specifies what image type is being loaded in. If None, will try to determine the type from the name.
            The name must have 'open' or 'dmlc' in the name.
        """
        img = Image.load(file_path)
        if im_type is not None:
            if _is_open_type(im_type):
                self.image_open = img
            elif _is_dmlc_type(im_type):
                self.image_dmlc = img
        else:
            # try to guess type by the name
            imtype = self._try_to_guess_image_type(osp.basename(file_path))
            if imtype is not None:
                self.load_image(file_path, imtype)
            else:
                raise ValueError("Image type was not given nor could it be determined from the path name. Please enter and image type.")
Exemplo n.º 26
0
 def test_nonsense(self):
     with self.assertRaises(TypeError):
         Image.load('blahblah')
Exemplo n.º 27
0
 def setUp(self):
     self.img = Image.load(tif_path)
     self.dcm = Image.load(dcm_path)
     array = np.arange(42).reshape(6, 7)
     self.arr = Image.load(array)
Exemplo n.º 28
0
 def load_demo_image(self):
     """Load the demo image."""
     demo_file = osp.join(osp.dirname(__file__), 'demo_files', 'flatsym', 'flatsym_demo.dcm')
     self.image = Image.load(demo_file)
Exemplo n.º 29
0
 def test_dicom(self):
     img = Image.load(dcm_path)
     self.assertIsInstance(img, DicomImage)
Exemplo n.º 30
0
 def setUpClass(cls):
     image = Image.load(cls.image_file_location)
     cls.profile = cls.klass(cls.center_point, cls.radius, image.array)
Exemplo n.º 31
0
 def __init__(self, settings):
     super().__init__(settings)
     self.image = Image.from_array(combine_surrounding_slices(self.settings.images, self.settings.LC_slice_num))
Exemplo n.º 32
0
class Starshot:
    """Class that can determine the wobble in a "starshot" image, be it gantry, collimator,
        couch or MLC. The image can be DICOM or a scanned film (TIF, JPG, etc).

    Attributes
    ----------
    image : :class:`~pylinac.core.image.Image`
    circle_profile : :class:`~pylinac.starshot.StarProfile`
    lines : :class:`~pylinac.starshot.LineManager`
    wobble : :class:`~pylinac.starshot.Wobble`

    Examples
    --------
    Run the demo:
        >>> Starshot().run_demo()

    Typical session:
        >>> img_path = r"C:/QA/Starshots/Coll.jpeg"
        >>> mystar = Starshot(img_path)
        >>> mystar.analyze()
        >>> print(mystar.return_results())
        >>> mystar.plot_analyzed_image()
    """
    def __init__(self, filepath=None):
        """
        Parameters
        ----------
        filepath : str, optional
            The path to the image file. If None, the image must be loaded later.
        """
        self.wobble = Wobble()
        self.tolerance = Tolerance(1, 'pixels')
        if filepath is not None:
            self.load_image(filepath)

    @classmethod
    def from_url(cls, url):
        """Instantiate from a URL.

        .. versionadded:: 0.7.1
        """
        obj = cls()
        obj.load_url(url)
        return obj

    def load_url(self, url):
        """Load from a URL.

        .. versionadded:: 0.7.1
        """
        try:
            import requests
        except ImportError:
            raise ImportError("Requests is not installed; cannot get the log from a URL")
        response = requests.get(url)
        if response.status_code != 200:
            raise ConnectionError("Could not connect to the URL")
        stream = BytesIO(response.content)
        self.load_image(stream)

    @classmethod
    def from_demo_image(cls):
        """Construct a Starshot instance and load the demo image.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_demo_image()
        return obj

    def load_demo_image(self):
        """Load the starshot demo image."""
        demo_file = osp.join(osp.dirname(__file__), 'demo_files', 'starshot', '10X_collimator.tif')
        self.load_image(demo_file)

    def load_image(self, filepath):
        """Load the image via the file path.

        Parameters
        ----------
        filepath : str
            Path to the file to be loaded.
        """
        self.image = Image(filepath)

    @classmethod
    def from_multiple_images(cls, filepath_list):
        """Construct a Starshot instance and load in and combine multiple images.

        .. versionadded:: 0.6

        Parameters
        ----------
        filepath_list : iterable
            An iterable of file paths to starshot images that are to be superimposed.
        """
        obj = cls()
        obj.load_multiple_images(filepath_list)
        return obj

    def load_multiple_images(self, filepath_list):
        """Load multiple images via the file path.

        .. versionadded:: 0.5.1

        Parameters
        ----------
        filepath_list : sequence
            An iterable sequence of filepath locations.
        """
        self.image = Image.from_multiples(filepath_list)

    @classmethod
    def from_multiple_images_UI(cls):
        """Construct a Starshot instance and load in and combine multiple images via a UI dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_multiple_images_UI()
        return obj

    def load_multiple_images_UI(self):
        """Load multiple images via a dialog box.

        .. versionadded:: 0.5.1
        """
        path_list = get_filenames_UI()
        if path_list:
            self.load_multiple_images(path_list)

    @classmethod
    def from_image_UI(cls):
        """Construct a Starshot instance and get the image via a UI dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_image_UI()
        return obj

    def load_image_UI(self):
        """Load the image by using a UI dialog box."""
        path = get_filepath_UI()
        if path:
            self.load_image(path)

    def _check_image_inversion(self):
        """Check the image for proper inversion, i.e. that pixel value increases with dose."""
        # sum the image along each axis
        x_sum = np.sum(self.image.array, 0)
        y_sum = np.sum(self.image.array, 1)

        # determine the point of max value for each sum profile
        xmaxind = np.argmax(x_sum)
        ymaxind = np.argmax(y_sum)

        # If that maximum point isn't near the center (central 1/3), invert image.
        center_in_central_third = ((xmaxind > len(x_sum) / 3 and xmaxind < len(x_sum) * 2 / 3) and
                               (ymaxind > len(y_sum) / 3 and ymaxind < len(y_sum) * 2 / 3))
        if not center_in_central_third:
            self.image.invert()

    def _get_reasonable_start_point(self):
        """Set the algorithm starting point automatically.

        Notes
        -----
        The determination of an automatic start point is accomplished by finding the Full-Width-80%-Max.
        Finding the maximum pixel does not consistently work, esp. in the presence of a pin prick. The
        FW80M is a more consistent metric for finding a good start point.
        """
        # sum the image along each axis within the central 1/3 (avoids outlier influence from say, gantry shots)
        top_third = int(self.image.array.shape[0]/3)
        bottom_third = int(top_third * 2)
        left_third = int(self.image.array.shape[1]/3)
        right_third = int(left_third * 2)
        central_array = self.image.array[top_third:bottom_third, left_third:right_third]

        x_sum = np.sum(central_array, 0)
        y_sum = np.sum(central_array, 1)

        # Calculate Full-Width, 80% Maximum
        fwxm_x_point = SingleProfile(x_sum).get_FWXM_center(80) + left_third
        fwxm_y_point = SingleProfile(y_sum).get_FWXM_center(80) + top_third

        # find maximum points
        x_max = np.unravel_index(np.argmax(central_array), central_array.shape)[1] + left_third
        y_max = np.unravel_index(np.argmax(central_array), central_array.shape)[0] + top_third

        # which one is closer to the center
        fwxm_dist = Point(fwxm_x_point, fwxm_y_point).dist_to(self.image.center)
        max_dist = Point(x_max, y_max).dist_to(self.image.center)

        if fwxm_dist < max_dist:
            center_point = Point(fwxm_x_point, fwxm_y_point)
        else:
            center_point = Point(x_max, y_max)

        return center_point

    @value_accept(radius=(0.2, 0.95), min_peak_height=(0.05, 0.95), SID=(40, 400))
    def analyze(self, radius=0.85, min_peak_height=0.25, tolerance=1.0, SID=100, start_point=None, fwhm=True, recursive=True):
        """Analyze the starshot image.

        Analyze finds the minimum radius and center of a circle that touches all the lines
        (i.e. the wobble circle diameter and wobble center).

        Parameters
        ----------
        radius : float, optional
            Distance in % between starting point and closest image edge; used to build the circular profile which finds
            the radiation lines. Must be between 0.05 and 0.95.
        min_peak_height : float, optional
            The percentage minimum height a peak must be to be considered a valid peak. A lower value catches
            radiation peaks that vary in magnitude (e.g. different MU delivered or gantry shot), but could also pick up noise.
            If necessary, lower value for gantry shots and increase for noisy images.
        tolerance : int, float, optional
            The tolerance to test against for a pass/fail result. If the image has a pixel/mm conversion factor, the tolerance is in mm.
            If the image has not conversion factor, the tolerance is in pixels.
        SID : int, float, optional
            The source-to-image distance in cm. If a value != 100 is passed in, results will be scaled to 100cm. E.g. a wobble of
            3.0 pixels at an SID of 150cm will calculate to 2.0 pixels [3 / (150/100)].

            .. note::
                For EPID images (e.g. superimposed collimator shots), the SID is in the DICOM file and this
                value will always be used if it can be found, otherwise the passed value will be used.
        start_point : 2-element iterable, optional
            A point where the algorithm should use for determining the circle profile.
            If None (default), will search for a reasonable maximum point nearest the center of the image.
        fwhm : bool
            If True (default), the center of the FWHM of the spokes will be determined.
            If False, the peak value location is used as the spoke center.

            .. note:: In practice, this ends up being a very small difference. Set to false if peak locations are offset or unexpected.
        recursive : bool
            If True (default), will recursively search for a "reasonable" wobble, meaning the wobble radius is
            <3mm. If the wobble found was unreasonable,
            the minimum peak height is iteratively adjusted from low to high at the passed radius.
            If for all peak heights at that point the wobble is still unreasonable, the
            radius is then iterated over from most distant inward.
            If False, will simply return the first determined value or raise error if a reasonable wobble could not be determined.

            .. warning:: It is strongly recommended to leave this setting at True, unless you have a strong reason.

        Raises
        ------
        AttributeError
            If an image has not yet been loaded.
        RuntimeError
            If a reasonable wobble value was not found.
        """
        if not self.image_is_loaded:
            raise AttributeError("Starshot image not yet loaded")

        self.tolerance.value = tolerance
        self._check_image_inversion()

        if start_point is None:
            start_point = self._get_reasonable_start_point()

        self._get_reasonable_wobble(start_point, SID, fwhm, min_peak_height, radius, recursive)

    def _get_reasonable_wobble(self, start_point, SID, fwhm, min_peak_height, radius, recursive):
        """Determine a wobble that is "reasonable". If recursive is false, the first iteration will be passed,
        otherwise the parameters will be tweaked to search for a reasonable wobble."""
        wobble_unreasonable = True
        focus_point = copy.copy(start_point)
        peak_gen = get_peak_height()
        radius_gen = get_radius()
        while wobble_unreasonable:
            try:
                self.circle_profile = StarProfile(self.image, focus_point, radius, min_peak_height, fwhm)
                if len(self.circle_profile.peaks) < 6:
                    raise ValueError
                self.lines = LineManager(self.circle_profile.peaks)
                self._find_wobble_minimize(SID)
            except ValueError:
                if not recursive:
                    raise RuntimeError("The algorithm was unable to properly detect the radiation lines. Try setting "
                                       "recursive to True or lower the minimum peak height")
            finally:
                # set the focus point to the wobble minimum
                focus_point = self.wobble.center
                # stop after first iteration if not recursive
                if not recursive:
                    wobble_unreasonable = False
                # otherwise, check if the wobble is reasonable
                else:
                    # if so, stop
                    if self.wobble.radius_mm < 3:
                        wobble_unreasonable = False
                    # otherwise, iterate through peak height
                    else:
                        try:
                            min_peak_height = next(peak_gen)
                        except StopIteration:
                            # if no height setting works, change the radius and reset the height
                            try:
                                radius = next(radius_gen)
                                peak_gen = get_peak_height()
                            except StopIteration:
                                raise RuntimeError("The algorithm was unable to determine a reasonable wobble. Try setting "
                                                   "recursive to False and manually adjusting algorithm parameters")

    @property
    def image_is_loaded(self):
        """Boolean property specifying if an image has been loaded."""
        return hasattr(self.image, 'size')

    def _scale_wobble(self, SID):
        """Scale the determined wobble by the SID.

        Parameters
        ----------
        SID : int, float
            Source to image distance in cm.
        """
        # convert wobble to mm if possible
        if self.image.dpmm is not None:
            self.tolerance.unit = 'mm'
            self.wobble.radius_mm = self.wobble.radius / self.image.dpmm
        else:
            self.tolerance.unit = 'pixels'
            self.wobble.radius_mm = self.wobble.radius

        if self.image.SID is not None:
            self.wobble.radius /= self.image.SID / 100
            self.wobble.radius_mm /= self.image.SID / 100
        else:
            self.wobble.radius /= SID / 100
            self.wobble.radius_mm /= SID / 100

    def _find_wobble_minimize(self, SID):
        """Find the minimum distance wobble location and radius to all radiation lines."""
        sp = copy.copy(self.circle_profile.center)

        def distance(p, lines):
            """Calculate the maximum distance to any line from the given point."""
            return max(line.distance_to(Point(p[0], p[1])) for line in lines)

        res = differential_evolution(distance, bounds=[(sp.x*0.95, sp.x*1.05), (sp.y*0.95, sp.y*1.05)], args=(self.lines,))

        self.wobble.radius = res.fun
        self.wobble.center = Point(res.x[0], res.x[1])

        self._scale_wobble(SID)

    @property
    def passed(self):
        """Boolean specifying whether the determined wobble was within tolerance."""
        return self.wobble.radius_mm * 2 < self.tolerance.value

    @property
    def _passfail_str(self):
        """Return a pass/fail string."""
        return 'PASS' if self.passed else 'FAIL'

    def return_results(self):
        """Return the results of the analysis.

        Returns
        -------
        string
            A string with a statement of the minimum circle.
        """
        string = ('\nResult: %s \n\n'
                  'The minimum circle that touches all the star lines has a diameter of %4.3g %s. \n\n'
                  'The center of the minimum circle is at %4.1f, %4.1f') % (self._passfail_str, self.wobble.radius_mm*2, self.tolerance.unit,
                                                                            self.wobble.center.x, self.wobble.center.y)
        return string

    def plot_analyzed_image(self, show=True):
        """Draw the star lines, profile circle, and wobble circle on a matplotlib figure.

        Parameters
        ----------
        show : bool
            Whether to actually show the image.
        """
        plt.clf()
        imgplot = plt.imshow(self.image.array, cmap=plt.cm.Greys)

        self.lines.plot(imgplot.axes)
        self.wobble.add_to_axes(imgplot.axes, edgecolor='green')
        self.circle_profile.add_to_axes(imgplot.axes, edgecolor='green')

        imgplot.axes.autoscale(tight=True)
        imgplot.axes.axis('off')

        if show:
            plt.show()

    def save_analyzed_image(self, filename, **kwargs):
        """Save the analyzed image plot to a file.

        Parameters
        ----------
        filename : str, IO stream
            The filename to save as. Format is deduced from string extention, if there is one. E.g. 'mystar.png' will
            produce a PNG image.

        kwargs
            All other kwargs are passed to plt.savefig().
        """
        self.plot_analyzed_image(show=False)
        plt.savefig(filename, **kwargs)

    def run_demo(self):
        """Demonstrate the Starshot module using the demo image."""
        self.load_demo_image()
        self.analyze()
        print(self.return_results())
        self.plot_analyzed_image()
Exemplo n.º 33
0
class Test_Image_Methods(unittest.TestCase):

    def setUp(self):
        self.img = Image(img_path)
        self.dcm = Image(dcm_path)
        small_array = np.arange(42).reshape(6,7)
        self.sm_arr = Image.from_array(small_array)

    def test_remove_edges(self):
        """Remove the edges from a pixel array."""
        crop = 15
        orig_shape = self.img.shape
        orig_dpi = self.img.dpi
        self.img.remove_edges(crop)
        new_shape = self.img.shape
        new_dpi = self.img.dpi
        self.assertEqual(new_shape[0]+crop*2, orig_shape[0])
        # ensure original metadata is still the same
        self.assertEqual(new_dpi, orig_dpi)

    def test_median_filter(self):
        filter_size = 3
        self.sm_arr.median_filter(filter_size)
        self.assertEqual(self.sm_arr.array[0, 0], 1)
        filter_size = 0.03
        self.sm_arr.median_filter(filter_size)

        self.assertRaises(ValueError, self.img.median_filter, 1.1)

    def test_ground(self):
        old_min_val = copy.copy(self.dcm.array.min())
        ground_val = self.dcm.ground()
        self.assertEqual(old_min_val, ground_val)
        # test that array was also changed
        self.assertAlmostEqual(self.dcm.array.min(), 0)

    def test_resize(self):
        new_size = (200, 300)
        self.img.resize(new_size)
        self.assertEqual(self.img.shape, new_size)

    def test_invert(self):
        self.img.invert()

    def test_dist2edge_min(self):
        dist = self.sm_arr.dist2edge_min(Point(1,3))
        self.assertEqual(dist, 1)

        dist = self.sm_arr.dist2edge_min((1,3))
        self.assertEqual(dist, 1)

    def test_center(self):
        self.assertIsInstance(self.img.center, Point)
        img_known_center = Point(512, 1702)
        dcm_known_center = Point(512, 384)
        self.assertEqual(self.img.center.x, img_known_center.x)
        self.assertEqual(self.dcm.center.y, dcm_known_center.y)

    def test_SID(self):
        self.assertEqual(self.dcm.SID, 1050)
        self.assertRaises(TypeError, setattr, self.dcm, 'SID', '105')

    def test_combine_multiples(self):
        bad_img_path = [dcm_path, img_path]
        self.assertRaises(AttributeError, Image.from_multiples, bad_img_path)

        good_img_path = [img_path, img_path]
        combined_img = Image.from_multiples(good_img_path)
        self.assertIsInstance(combined_img, Image)

    def test_plot(self):
        self.img.plot()  # shouldn't raise
Exemplo n.º 34
0
class PicketFence:
    """A class used for analyzing EPID images where radiation strips have been formed by the
    MLCs. The strips are assumed to be parallel to one another and normal to the image edge;
    i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting
    for any angle.

    Attributes
    ----------
    pickets: list
        Holds :class:`~pylinac.picketfence.Picket` objects.
    image: :class:`~pylinac.core.image.Image` object.

    Examples
    --------
    Run the demo::
        >>> PicketFence().run_demo()

    Typical session:
        >>> img_path = r"C:/QA/June/PF.dcm"  # the EPID image
        >>> mypf = PicketFence(img_path)
        >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3)
        >>> print(mypf.return_results())
        >>> mypf.plot_analyzed_image()
    """
    def __init__(self, filename=None, filter=None):
        self.pickets = []
        self._action_lvl = None
        if filename is not None:
            self.load_image(filename, filter)

    def _clear_attrs(self):
        """Clear attributes; necessary when new image loaded or analysis done on same image."""
        self.pickets = []
        self._action_lvl = None

    @property
    def passed(self):
        """Boolean specifying if all MLC positions were within tolerance."""
        for picket in self.pickets:
            for meas in picket.mlc_meas:
                if not meas.passed:
                    return False
        return True

    @property
    def percent_passing(self):
        """Return the percentage of MLC positions under tolerance."""
        num = 0
        num_pass = 0
        for picket in self.pickets:
            num += len(picket._error_array)
            num_pass += sum(picket._error_array < picket._tolerance)
        pct_pass = 100 * num_pass / num
        return pct_pass

    @property
    def max_error(self):
        """Return the maximum error found."""
        max_error = 0
        for idx, picket in enumerate(self.pickets):
            if picket.max_error > max_error:
                max_error = picket.max_error
        return max_error

    @property
    def max_error_picket(self):
        """Return the picket number where the maximum error occured."""
        max_error = 0
        where_at = 0
        for idx, picket in enumerate(self.pickets):
            if picket.max_error > max_error:
                max_error = picket.max_error
                where_at = idx
        return where_at

    @property
    def max_error_leaf(self):
        """Return the leaf that had the maximum error."""
        picket = self.pickets[self.max_error_picket]
        return np.argmax(picket._error_array)

    @property
    def abs_median_error(self):
        """Return the median error found."""
        median_error = []
        for picket in self.pickets:
            median_error.append(picket.abs_median_error)
        return max(median_error)

    @property
    def _action_lvl_set(self):
        if self._action_lvl is not None:
            return True
        else:
            return False

    @property
    def num_pickets(self):
        """Return the number of pickets determined."""
        return len(self.pickets)

    @classmethod
    def from_demo_image(cls):
        """Construct a PicketFence instance using the demo image.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_demo_image()
        return obj

    def load_demo_image(self):
        """Load the demo image that is included with pylinac."""
        im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm')
        self.load_image(im_open_path)

    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image(file_path)
        if isinstance(filter, int):
            self.image.array = spfilt.median_filter(self.image.array, size=filter)
        self._clear_attrs()

    @classmethod
    def from_image_UI(cls):
        """Construct a PicketFence instance and load an image using a dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_image_UI()
        return obj

    def load_image_UI(self):
        """Load the image using a UI dialog box."""
        path = get_filepath_UI()
        self.load_image(path)

    def run_demo(self, tolerance=0.5):
        """Run the Picket Fence demo using the demo image. See analyze() for parameter info."""
        self.load_demo_image()
        self.analyze(tolerance)
        print(self.return_results())
        self.plot_analyzed_image()

    def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False):
        """Analyze the picket fence image.

        Parameters
        ----------
        tolerance : int, float
            The tolerance of difference in mm between an MLC pair position and the
            picket fit line.
        action_tolerance : int, float, None
            If None (default), no action tolerance is set or compared to.
            If an int or float, the MLC pair measurement is also compared to this
            tolerance. Must be lower than tolerance. This value is usually meant
            to indicate an "action" is necessary on the part of the physicist to
            resolve the issue.
        hdmlc : bool
            If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed.
            If True, an HD (2.5/5mm leaves) Millennium is assumed.
        """
        if action_tolerance is not None and tolerance < action_tolerance:
            raise ValueError("Tolerance cannot be lower than the action tolerance")

        """Pre-analysis"""
        self._clear_attrs()
        self._action_lvl = action_tolerance
        self.image.check_inversion()
        self._threshold()
        self._find_orientation()

        """Analysis"""
        self._construct_pickets(tolerance, action_tolerance)
        leaf_centers = self._find_leaf_centers(hdmlc)
        self._calc_mlc_positions(leaf_centers)
        self._calc_mlc_error()

    def _construct_pickets(self, tolerance, action_tolerance):
        """Construct the Picket instances."""
        if self.orientation == orientations['UD']:
            leaf_prof = np.median(self._analysis_array, 0)
        else:
            leaf_prof = np.median(self._analysis_array, 1)
        leaf_prof = Profile(leaf_prof)
        _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=0.01, min_peak_height=0.5)
        for peak in range(len(peak_idxs)):
            self.pickets.append(Picket(self.image, tolerance, self.orientation, action_tolerance))

    def _find_leaf_centers(self, hdmlc):
        """Return the leaf centers perpendicular to the leaf motion."""
        # generate some settings
        sm_lf_wdth = 5 * self.image.dpmm
        bg_lf_wdth = sm_lf_wdth * 2
        if hdmlc:
            sm_lf_wdth /= 2
            bg_lf_wdth /= 2
        self._sm_lf_meas_wdth = slmw = int(round(sm_lf_wdth*3/4))
        self._bg_lf_meas_wdth = blmw = int(round(bg_lf_wdth*3/4))
        bl_ex = int(bg_lf_wdth/4)
        sm_ex = int(sm_lf_wdth/4)

        # generate leaf profile
        if self.orientation == orientations['UD']:
            leaf_prof = np.mean(self._analysis_array, 1)
            center = self.image.center.y
        else:
            leaf_prof = np.mean(self._analysis_array, 0)
            center = self.image.center.x
        leaf_prof = Profile(leaf_prof)

        # ground profile to reasonable level
        _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=self._sm_lf_meas_wdth, exclude_lt_edge=sm_ex,
                                            exclude_rt_edge=sm_ex)
        min_val = leaf_prof.y_values[peak_idxs[0]:peak_idxs[-1]].min()
        leaf_prof.y_values[leaf_prof.y_values < min_val] = min_val

        # remove unevenness in signal
        leaf_prof.y_values = signal.detrend(leaf_prof.y_values, bp=[int(len(leaf_prof.y_values)/3), int(len(leaf_prof.y_values)*2/3)])
        _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=self._sm_lf_meas_wdth, exclude_lt_edge=sm_ex, exclude_rt_edge=sm_ex)
        leaf_range = (peak_idxs[-1] - peak_idxs[0]) / self.image.dpmm  # mm
        sm_lf_range = 220  # mm

        # find leaf peaks
        if leaf_range > sm_lf_range:
            lt_biglittle_lf_bndry = int(round(center - 100 * self.image.dpmm))
            rt_biglittle_lf_bndry = int(round(center + 100 * self.image.dpmm))
            pp = leaf_prof.subdivide([lt_biglittle_lf_bndry, rt_biglittle_lf_bndry], slmw)
            if len(pp) != 3:
                raise ValueError("3 Profiles weren't found but should have been")
            # Left Big MLC region
            _, peak_idxs = pp[0].find_peaks(min_peak_distance=blmw, exclude_lt_edge=bl_ex)
            peak_diff = np.diff(peak_idxs).mean()
            lt_v_idx = np.array(peak_idxs[:-1]) + peak_diff/2

            # Middle, small MLC region
            _, peak_idxs = pp[1].find_peaks(min_peak_distance=slmw)
            peak_diff = np.diff(peak_idxs).mean()
            mid_v_idx = np.array(peak_idxs[:-1]) + peak_diff / 2

            # Right Big MLC region
            _, peak_idxs = pp[2].find_peaks(min_peak_distance=blmw,
                                            exclude_rt_edge=bl_ex)
            peak_diff = np.diff(peak_idxs).mean()
            rt_v_idx = np.array(peak_idxs[:-1]) + peak_diff / 2
            leaf_center_idxs = np.concatenate((lt_v_idx, mid_v_idx, rt_v_idx))
        else:
            _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=slmw, exclude_lt_edge=sm_ex,
                                                exclude_rt_edge=sm_ex)
            _, peak_idxs = leaf_prof.find_FWXM_peaks(min_peak_distance=slmw, interpolate=True)
            peak_diff = np.diff(peak_idxs).mean()
            leaf_center_idxs = np.array(peak_idxs[:-1]) + peak_diff / 2
        return leaf_center_idxs

    def _calc_mlc_positions(self, leaf_centers):
        """Calculate the positions of all the MLC pairs."""
        diff = np.diff(leaf_centers)
        sample_width = np.round(np.median(diff*2/5)/2).astype(int)

        for mlc_num, mlc_peak_loc in enumerate(np.round(leaf_centers).astype(int)):
            mlc_rows = np.arange(mlc_peak_loc-sample_width, mlc_peak_loc+sample_width+1)
            if self.orientation == orientations['UD']:
                pix_vals = np.median(self._analysis_array[mlc_rows, :], axis=0)
            else:
                pix_vals = np.median(self._analysis_array[:, mlc_rows], axis=1)
            prof = Profile(pix_vals)
            prof.find_FWXM_peaks(fwxm=80, min_peak_distance=0.01, min_peak_height=0.5, interpolate=True)
            for idx, peak in enumerate(prof.peaks):
                if self.orientation == orientations['UD']:
                    meas = MLC_Meas((peak.idx, mlc_rows[0]), (peak.idx, mlc_rows[-1]))
                else:
                    meas = MLC_Meas((mlc_rows[0], peak.idx), (mlc_rows[-1], peak.idx))
                self.pickets[idx].mlc_meas.append(meas)

    def _calc_mlc_error(self):
        """Calculate the error of the MLC positions relative to the picket fit."""
        for picket in self.pickets:
            picket.fit_poly()
            picket.calc_mlc_errors()

    def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, show=True):
        """Plot the analyzed image.

        Parameters
        ----------
        guard_rails : bool
            Do/don't plot the picket "guard rails".
        mlc_peaks : bool
            Do/don't plot the MLC positions.
        overlay : bool
            Do/don't plot the alpha overlay of the leaf status.
        """
        # plot the image
        plt.clf()
        ax = plt.imshow(self.image.array, cmap=plt.cm.Greys)

        # plot guard rails and mlc peaks as desired
        for p_num, picket in enumerate(self.pickets):
            if guard_rails:
                picket.add_guards_to_axes(ax.axes)
            if mlc_peaks:
                for idx, mlc_meas in enumerate(picket.mlc_meas):

                    if not mlc_meas.passed:
                        color = 'r'
                    elif self._action_lvl_set and not mlc_meas.passed_action:
                        color = 'm'
                    else:
                        color = 'b'
                    mlc_meas.add_to_axes(ax.axes, color=color, width=1.5)
        # plot the overlay if desired.
        if overlay:
            for mlc_num, mlc in enumerate(self.pickets[0].mlc_meas):

                below_tol = True
                if self._action_lvl_set:
                    below_action = True
                for picket in self.pickets:
                    if not picket.mlc_passed(mlc_num):
                        below_tol = False
                    if self._action_lvl_set and not picket.mlc_passed_action(mlc_num):
                        below_action = False
                if below_tol:
                    if self._action_lvl_set and not below_action:
                        color = 'm'
                    else:
                        color = 'g'
                else:
                    color = 'r'
                if self.orientation == orientations['UD']:
                    r = Rectangle(max(self.image.shape)*2, self._sm_lf_meas_wdth, (mlc.center.x, mlc.center.y))
                else:
                    r = Rectangle(self._sm_lf_meas_wdth, max(self.image.shape) * 2, (mlc.center.x, mlc.center.y))
                r.add_to_axes(ax.axes, edgecolor='none', fill=True, alpha=0.1, facecolor=color)

        plt.xlim([0, self.image.shape[1]])
        plt.ylim([0, self.image.shape[0]])

        plt.axis('off')

        if show:
            plt.show()

    def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, **kwargs):
        """Save the analyzed figure to a file."""
        self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, show=False)
        plt.savefig(filename, **kwargs)

    def return_results(self):
        """Return results of analysis. Use with print()."""
        pass_pct = self.percent_passing
        string = "Picket Fence Results: \n{:2.1f}% " \
                 "Passed\nMedian Error: {:2.3f}mm \n" \
                 "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error,
                                                                                                   self.max_error_picket,
                                                                                                  self.max_error_leaf)
        return string

    def _threshold(self):
        """Threshold the image by subtracting the minimum value. Allows for more accurate image orientation determination.
        """
        col_prof = np.median(self.image.array, 0)
        col_prof = Profile(col_prof)
        row_prof = np.median(self.image.array, 1)
        row_prof = Profile(row_prof)
        _, r_peak_idx = row_prof.find_peaks(min_peak_distance=0.01, exclude_lt_edge=0.05, exclude_rt_edge=0.05)
        _, c_peak_idx = col_prof.find_peaks(min_peak_distance=0.01, exclude_lt_edge=0.05, exclude_rt_edge=0.05)
        min_val = self.image.array[r_peak_idx[0]:r_peak_idx[-1], c_peak_idx[0]:c_peak_idx[-1]].min()
        self._analysis_array = self.image.array.copy()
        self._analysis_array[self._analysis_array < min_val] = min_val
        self._analysis_array -= min_val

    def _find_orientation(self):
        """Determine the orientation of the radiation strips by examining percentiles of the sum of each axes of the image.
        A high standard deviation is a surrogate for the axis the pickets are along.
        """
        row_sum = np.sum(self._analysis_array, 0)
        col_sum = np.sum(self._analysis_array, 1)
        row80, row90 = np.percentile(row_sum, [80, 90])
        col80, col90 = np.percentile(col_sum, [80, 90])
        row_range = row90 - row80
        col_range = col90 - col80
        # The true picket side will have a greater difference in
        # percentiles than will the non-picket size.
        if row_range < col_range:
            self.orientation = orientations['LR']
        else:
            self.orientation = orientations['UD']
Exemplo n.º 35
0
 def setUp(self):
     self.img = Image(img_path)
     self.dcm = Image(dcm_path)
     small_array = np.arange(42).reshape(6,7)
     self.sm_arr = Image.from_array(small_array)
Exemplo n.º 36
0
class Starshot:
    """Class that can determine the wobble in a "starshot" image, be it gantry, collimator,
        couch or MLC. The image can be DICOM or a scanned film (TIF, JPG, etc).

    Attributes
    ----------
    image : :class:`~pylinac.core.image.Image`
    circle_profile : :class:`~pylinac.starshot.StarProfile`
    lines : list of :class:`~pylinac.core.geometry.Line` instances
    wobble : :class:`~pylinac.starshot.Wobble`

    Examples
    --------
    Run the demo:
        >>> Starshot().run_demo()

    Typical session:
        >>> img_path = r"C:/QA/Starshots/Coll.jpeg"
        >>> mystar = Starshot(img_path)
        >>> mystar.analyze()
        >>> print(mystar.return_results())
        >>> mystar.plot_analyzed_image()
    """
    def __init__(self, filepath=None):
        # self.image = Image  # The image array and image property structure
        self.circle_profile = StarProfile()  # a circular profile which will detect radiation line locations
        self.lines = []  # a list which will hold Line instances representing radiation lines.
        self.wobble = Wobble()  # A Circle representing the radiation wobble
        self.tolerance = Tolerance(1, 'pixels')
        if filepath is not None:
            self.load_image(filepath)

    @classmethod
    def from_demo_image(cls):
        """Construct a Starshot instance and load the demo image.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_demo_image()
        return obj

    def load_demo_image(self):
        """Load the starshot demo image.

        The Pylinac package comes with compressed demo images.
        When called, the function unpacks the demo image and loads it.

        Parameters
        ----------
        cleanup : boolean
            If True (default), the extracted demo file is deleted (but not the compressed version).
            If False, leaves the extracted file alone after loading. Useful when using the demo image a lot,
            or you don't mind using the extra space.
        """
        demo_folder = osp.join(osp.dirname(__file__), 'demo_files', 'starshot')
        demo_file = osp.join(demo_folder, '10X_collimator.tif')
        # demo_file = osp.join(demo_folder, 'DHMC_starshot.dcm')
        self.load_image(demo_file)

    def load_image(self, filepath):
        """Load the image via the file path.

        Parameters
        ----------
        filepath : str
            Path to the file to be loaded.
        """
        self.image = Image(filepath)
        # apply filter if it's a large image to reduce noise
        if self.image.shape[0] > 1100:
            self.image.median_filter(0.002)

    @classmethod
    def from_multiple_images(cls, filepath_list):
        """Construct a Starshot instance and load in and combine multiple images.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_multiple_images(filepath_list)
        return obj

    def load_multiple_images(self, filepath_list):
        """Load multiple images via the file path.

        .. versionadded:: 0.5.1

        Parameters
        ----------
        filepath_list : sequence
            An iterable sequence of filepath locations.
        """
        self.image = Image.from_multiples(filepath_list)

    @classmethod
    def from_multiple_images_UI(cls):
        """Construct a Starshot instance and load in and combine multiple images via a UI dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_multiple_images_UI()
        return obj

    def load_multiple_images_UI(self):
        """Load multiple images via a dialog box.

        .. versionadded:: 0.5.1
        """
        path_list = get_filenames_UI()
        if path_list:
            self.load_multiple_images(path_list)

    @classmethod
    def from_image_UI(cls):
        """Construct a Starshot instance and get the image via a UI dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_image_UI()
        return obj

    def load_image_UI(self):
        """Load the image by using a UI dialog box."""
        path = get_filepath_UI()
        if path:
            self.load_image(path)

    @property
    def start_point(self):
        """The start point of the wobble search algorithm.

        After analysis this point is the wobble center.
        """
        return self.circle_profile.center

    def _check_image_inversion(self):
        """Check the image for proper inversion, i.e. that pixel value increases with dose.

        Notes
        -----
        Inversion is checked by the following:
        - Summing the image along both horizontal and vertical directions.
        - If the maximum point of both horizontal and vertical is in the middle 1/3, the image is assumed to be correct.
        - Otherwise, invert the image.
        """

        # sum the image along each axis
        x_sum = np.sum(self.image.array, 0)
        y_sum = np.sum(self.image.array, 1)

        # determine the point of max value for each sum profile
        xmaxind = np.argmax(x_sum)
        ymaxind = np.argmax(y_sum)

        # If that maximum point isn't near the center (central 1/3), invert image.
        center_in_central_third = ((xmaxind > len(x_sum) / 3 and xmaxind < len(x_sum) * 2 / 3) and
                               (ymaxind > len(y_sum) / 3 and ymaxind < len(y_sum) * 2 / 3))

        if not center_in_central_third:
            self.image.invert()

    def _auto_set_start_point(self):
        """Set the algorithm starting point automatically.

        Notes
        -----
        The determination of an automatic start point is accomplished by finding the Full-Width-80%-Max.
        Finding the maximum pixel does not consistently work, esp. in the presence of a pin prick. The
        FW80M is a more consistent metric for finding a good start point.
        """
        # sum the image along each axis within the central 1/3 (avoids outlier influence from say, gantry shots)
        top_third = int(self.image.array.shape[0]/3)
        bottom_third = int(top_third * 2)
        left_third = int(self.image.array.shape[1]/3)
        right_third = int(left_third * 2)
        central_array = self.image.array[top_third:bottom_third, left_third:right_third]

        x_sum = np.sum(central_array, 0)
        y_sum = np.sum(central_array, 1)

        # Calculate Full-Width, 80% Maximum
        fwxm_x_point = SingleProfile(x_sum).get_FWXM_center(80) + left_third
        fwxm_y_point = SingleProfile(y_sum).get_FWXM_center(80) + top_third

        # find maximum points
        x_max = np.unravel_index(np.argmax(central_array), central_array.shape)[1]  + left_third
        y_max = np.unravel_index(np.argmax(central_array), central_array.shape)[0] + top_third

        # which one is closer to the center
        fwxm_dist = Point(fwxm_x_point, fwxm_y_point).dist_to(self.image.center)
        max_dist = Point(x_max, y_max).dist_to(self.image.center)

        if fwxm_dist < max_dist:
            center_point = Point(fwxm_x_point, fwxm_y_point)
        else:
            center_point = Point(x_max, y_max)

        self.circle_profile.center = center_point

    @value_accept(radius=(0.2, 0.95), min_peak_height=(0.1, 0.9), SID=(40, 400))
    def analyze(self, radius=0.85, min_peak_height=0.25, SID=100, fwhm=True, recursive=True):
        """Analyze the starshot image.

        Analyze finds the minimum radius and center of a circle that touches all the lines
        (i.e. the wobble circle diameter and wobble center).

        Parameters
        ----------
        radius : float, optional
            Distance in % between starting point and closest image edge; used to build the circular profile which finds
            the radiation lines. Must be between 0.05 and 0.95.
        min_peak_height : float, optional
            The percentage minimum height a peak must be to be considered a valid peak. A lower value catches
            radiation peaks that vary in magnitude (e.g. different MU delivered), but could also pick up noise.
            Increase value for noisy images.
        SID : int, float, optional
            The source-to-image distance in cm. If a value != 100 is passed in, results will be scaled to 100cm. E.g. a wobble of
            3.0 pixels at an SID of 150cm will calculate to 2.0 pixels [3 / (150/100)].

            .. note::
                For EPID images (e.g. superimposed collimator shots), the SID is in the DICOM file, and this
                value will always be used if it can be found, otherwise the passed value will be used.

        fwhm : bool
            If True (default), the center of the FWHM of the spokes will be determined.
            If False, the peak value location is used as the spoke center.
            .. note:: In practice, this ends up being a very small difference. Set to false if behavior is unexpected.
        recursive : bool
            If True (default), will recursively search for a "reasonable" wobble, meaning the wobble radius is
            <5mm, and the wobble location is somewhere near the starting point. If the wobble found was unreasonable,
            the minimum peak height is lowered incrementally. If at that point the wobble is still unreasonable, the
            radius is lowered (closer to center) and the search (including minimum peak height)
            If False, will not

        Raises
        ------
        AttributeError
            If an image has not yet been loaded.
        """
        # error checking
        if not self.image_is_loaded:
            raise AttributeError("Starshot image not yet loaded")

        # check inversion
        self._check_image_inversion()

        # set starting point automatically if not yet set
        if not self._start_point_is_set:
            self._auto_set_start_point()

        wobble_unreasonable = True
        orig_peak_height = copy.copy(min_peak_height)
        while wobble_unreasonable:
            # set profile extraction radius
            self.circle_profile.radius = self._convert_radius_perc2pix(radius)
            # extract the circle profile
            self.circle_profile.get_median_profile(self.image.array)
            # find the radiation lines using the peaks of the profile
            self.lines = self.circle_profile.find_rad_lines(min_peak_height, fwhm=fwhm)

            self.find_wobble_minimize(SID)
            # find the wobble
            # self._find_wobble_2step(SID)
            if not recursive:
                wobble_unreasonable = False
            else:
                if self.wobble.radius_mm < 5 and self.wobble.center.dist_to(self.start_point) < 50:
                    wobble_unreasonable = False
                else:
                    if min_peak_height > 0.15:
                        min_peak_height -= 0.07
                    elif radius > 0.3:
                        min_peak_height = orig_peak_height
                        radius -= 0.05
                    else:
                        raise RuntimeError("The algorithm was unable to determine a reasonable wobble. Try setting"
                                           "recursive to False")

    def _convert_radius_perc2pix(self, radius):
        """Convert a percent radius to distance in pixels, based on the distance from center point to image
            edge.

        Parameters
        ----------
        radius : float
            The radius ratio (e.g. 0.5).
        """
        dist = self.image.dist2edge_min(self.circle_profile.center)
        return dist*radius

    @property
    def image_is_loaded(self):
        """Boolean property specifying if an image has been loaded."""
        try:
            self.image.size
            return True
        except AttributeError:
            return False

    @property
    def _start_point_is_set(self):
        """Boolean specifying if a start point has been set."""
        if self.circle_profile.center.x == 0:
            return False
        else:
            return True

    def _scale_wobble(self, SID):
        """Scale the determined wobble by the SID.

        Parameters
        ----------
        SID : int, float
            Source to image distance in cm.
        """
        # convert wobble to mm if possible
        if self.image.dpmm is not None:
            self._tolerance_unit = 'mm'
            self.wobble.radius_mm = self.wobble.radius / self.image.dpmm
        else:
            self._tolerance_unit = 'pixels'
            self.wobble.radius_mm = self.wobble.radius

        if self.image.SID is not None:
            self.wobble.radius /= self.image.SID / 100
            self.wobble.radius_mm /= self.image.SID / 100
        else:
            self.wobble.radius /= SID / 100
            self.wobble.radius_mm /= SID / 100

    def find_wobble_minimize(self, SID):
        sp = copy.copy(self.circle_profile.center)

        def f(p, lines):
            return max(line.distance_to(Point(p[0], p[1])) for line in lines)

        res = differential_evolution(f, bounds=[(sp.x*0.9, sp.x*1.1), (sp.y*0.9, sp.y*1.1)], args=(self.lines,))

        self.wobble.radius = res.fun
        self.wobble.center = Point(res.x[0], res.x[1])

        self._scale_wobble(SID)

    def _find_wobble_2step(self, SID):
        """Find the smallest radius ("wobble") and center of a circle that touches all the star lines.

        Notes
        -----
        Wobble determination is accomplished by two rounds of searching. The first round finds the radius and center down to
        the nearest pixel. The second round finds the center and radius down to sub-pixel precision using parameter scale.
        This methodology is faster than one round of searching at sub-pixel precision.

        See Also
        --------
        analyze : Further parameter info.
        """
        sp = copy.copy(self.circle_profile.center)

        # first round of searching; this finds the circle to the nearest pixel
        normal_tolerance, normal_scale = 0.05, 1.0
        self._find_wobble(normal_tolerance, sp, normal_scale)

        # second round of searching; this finds the circle down to sub-pixel precision
        small_tolerance, small_scale = 0.0001, 100.0
        self._find_wobble(small_tolerance, self.wobble.center, small_scale)

        # scale the wobble based on the SID
        self._scale_wobble(SID)

    def _find_wobble(self, tolerance, start_point, scale):
        """An iterative method that moves element by element to the point of minimum distance to all radiation lines.

        Parameters
        ----------
        tolerance : float
            The value differential between the outside elements and center element to stop the algorithm.
        start_point : geometry.Point
            The starting point for the algorithm.
        scale : int, float
            The scale of the search in pixels. E.g. 0.1 searches at 0.1 pixel precision.
        """
        # TODO: use an optimization function instead of evolutionary search
        sp = start_point
        # init conditions; initialize a 3x3 "ones" matrix and make corner value 0 to start minimum distance search.
        distmax = np.ones((3, 3))
        distmax[0, 0] = 0

        # find min point within the given tolerance
        while np.any(distmax < distmax[1, 1] - tolerance):  # while any edge pixel value + tolerance is less than the center one...
            # find which pixel that is lower than center pixel
            min_idx = np.unravel_index(distmax.argmin(),distmax.shape)
            # set new starting point to min dist index point
            sp.y += (min_idx[0] - 1)/scale
            sp.x += (min_idx[1] - 1)/scale
            for x in np.arange(-1,2):
                for y in np.arange(-1,2):
                    point = Point(y=sp.y+(y/scale), x=sp.x+(x/scale))
                    distmax[y+1, x+1] = np.max([line.distance_to(point) for line in self.lines])

        self.wobble.radius = distmax[1,1]
        self.wobble.center = sp

    @property
    def passed(self):
        """Boolean specifying whether the determined wobble was within tolerance."""
        if self.wobble.radius_mm * 2 < self.tolerance.value:
            return True
        else:
            return False

    def return_results(self):
        """Return the results of the analysis.

        Returns
        -------
        string
            A string with a statement of the minimum circle.
        """
        if self.passed:
            passfailstr = 'PASS'
        else:
            passfailstr = 'FAIL'

        string = ('\nResult: %s \n\n'
                  'The minimum circle that touches all the star lines has a diameter of %4.3g %s. \n\n'
                  'The center of the minimum circle is at %4.1f, %4.1f') % (passfailstr, self.wobble.radius_mm*2, self._tolerance_unit,
                                                                            self.wobble.center.x, self.wobble.center.y)
        return string

    def plot_analyzed_image(self, show=True):
        """Draw the star lines, profile circle, and wobble circle on a matplotlib figure.

        Parameters
        ----------
        show : bool
            Whether to actually show the image.
        """
        plt.clf()
        imgplot = plt.imshow(self.image.array, cmap=plt.cm.Greys)

        # plot radiation lines
        for line in self.lines:
            line.add_to_axes(imgplot.axes, color='blue')

        # plot wobble circle
        self.wobble.add_to_axes(imgplot.axes, edgecolor='green')

        # plot profile circle
        self.circle_profile.add_to_axes(imgplot.axes, edgecolor='green')

        # tighten plot around image
        imgplot.axes.autoscale(tight=True)

        imgplot.axes.axis('off')

        # Finally, show it all
        if show:
            plt.show()

    def save_analyzed_image(self, filename, **kwargs):
        """Save the analyzed image plot to a file.

        Parameters
        ----------
        filename : str, IO stream
            The filename to save as. Format is deduced from string extention, if there is one. E.g. 'mystar.png' will
            produce a PNG image.

        kwargs
            All other kwargs are passed to plt.savefig().
        """
        self.plot_analyzed_image(show=False)

        plt.savefig(filename, **kwargs)

    def run_demo(self):
        """Demonstrate the Starshot module using the demo image."""
        self.load_demo_image()
        self.analyze()
        print(self.return_results())
        self.plot_analyzed_image()
Exemplo n.º 37
0
 def setUp(self):
     self.img = Image.load(tif_path)
     self.dcm = Image.load(dcm_path)
     array = np.arange(42).reshape(6, 7)
     self.arr = Image.load(array)
Exemplo n.º 38
0
 def setUpClass(cls):
     image = Image.load(cls.image_file_location)
     cls.profile = cls.klass(cls.center_point, cls.radius, image.array)
Exemplo n.º 39
0
 def test_array(self):
     arr = np.arange(36).reshape(6, 6)
     img = Image.load(arr)
     self.assertIsInstance(img, ArrayImage)
Exemplo n.º 40
0
class PicketFence:
    """A class used for analyzing EPID images where radiation strips have been formed by the
    MLCs. The strips are assumed to be parallel to one another and normal to the image edge;
    i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting
    for any angle.

    Attributes
    ----------
    pickets: :class:`~pylinac.picketfence.PicketHandler`
    image: :class:`~pylinac.core.image.Image`

    Examples
    --------
    Run the demo::
        >>> PicketFence().run_demo()

    Typical session:
        >>> img_path = r"C:/QA/June/PF.dcm"  # the EPID image
        >>> mypf = PicketFence(img_path)
        >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3)
        >>> print(mypf.return_results())
        >>> mypf.plot_analyzed_image()
    """
    def __init__(self, filename=None, filter=None):
        """
        Parameters
        ----------
        filename : str, None
            Name of the file as a string. If None, image must be loaded later.
        filter : int, None
            The filter size to apply to the image upon load.
        """
        if filename is not None:
            self.load_image(filename, filter)

    @classmethod
    def from_url(cls, url, filter=None):
        """Instantiate from a URL.

        .. versionadded:: 0.7.1
        """
        obj = cls()
        obj.load_url(url, filter=filter)
        return obj

    def load_url(self, url, filter=None):
        """Load from a URL.

        .. versionadded:: 0.7.1
        """
        try:
            import requests
        except ImportError:
            raise ImportError("Requests is not installed; cannot get the log from a URL")
        response = requests.get(url)
        if response.status_code != 200:
            raise ConnectionError("Could not connect to the URL")
        stream = BytesIO(response.content)
        self.load_image(stream, filter=filter)

    @property
    def passed(self):
        """Boolean specifying if all MLC positions were within tolerance."""
        return self.pickets.passed

    @property
    def percent_passing(self):
        """Return the percentage of MLC positions under tolerance."""
        num = 0
        num_pass = 0
        for picket in self.pickets:
            num += len(picket.error_array)
            num_pass += sum(picket.error_array < self.settings.tolerance)
        pct_pass = 100 * num_pass / num
        return pct_pass

    @property
    def max_error(self):
        """Return the maximum error found."""
        return max(picket.max_error for picket in self.pickets)

    @property
    def max_error_picket(self):
        """Return the picket number where the maximum error occured."""
        return np.argmax([picket.max_error for picket in self.pickets])

    @property
    def max_error_leaf(self):
        """Return the leaf that had the maximum error."""
        picket = self.pickets[self.max_error_picket]
        return np.argmax(picket.error_array)

    @property
    @lru_cache()
    def abs_median_error(self):
        """Return the median error found."""
        return np.median(np.hstack([picket.error_array for picket in self.pickets]))

    @property
    def num_pickets(self):
        """Return the number of pickets determined."""
        return len(self.pickets)

    @classmethod
    def from_demo_image(cls, filter=None):
        """Construct a PicketFence instance using the demo image.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_demo_image(filter=filter)
        return obj

    def load_demo_image(self, filter=None):
        """Load the demo image that is included with pylinac."""
        im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm')
        self.load_image(im_open_path, filter=filter)

    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image(file_path)
        if isinstance(filter, int):
            self.image.median_filter(size=filter)
        self._check_for_noise()
        self.image.check_inversion()

    @classmethod
    def from_image_UI(cls, filter=None):
        """Construct a PicketFence instance and load an image using a dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_image_UI(filter=filter)
        return obj

    def load_image_UI(self, filter=None):
        """Load the image using a UI dialog box."""
        path = get_filepath_UI()
        self.load_image(path, filter=filter)

    def _check_for_noise(self):
        """Check if the image has extreme noise (dead pixel, etc) by comparing
        min/max to 1/99 percentiles and smoothing if need be."""
        while self._has_noise():
            self.image.median_filter()

    def _has_noise(self):
        """Helper method to determine if there is spurious signal in the image."""
        min = self.image.array.min()
        max = self.image.array.max()
        near_min, near_max = np.percentile(self.image.array, [0.5, 99.5])
        max_is_extreme = max > near_max * 2
        min_is_extreme = (min < near_min) and (abs(near_min - min) > 0.2 * near_max)
        return max_is_extreme or min_is_extreme

    def _adjust_for_sag(self, sag):
        """Roll the image to adjust for EPID sag."""
        sag_pixels = int(round(sag * self.settings.dpmm))
        direction = 'y' if self.orientation == orientations['UD'] else 'x'
        self.image.roll(direction, sag_pixels)

    def run_demo(self, tolerance=0.5):
        """Run the Picket Fence demo using the demo image. See analyze() for parameter info."""
        self.load_demo_image()
        self.analyze(tolerance)
        print(self.return_results())
        self.plot_analyzed_image()

    def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False, num_pickets=None, sag_adjustment=0):
        """Analyze the picket fence image.

        Parameters
        ----------
        tolerance : int, float
            The tolerance of difference in mm between an MLC pair position and the
            picket fit line.
        action_tolerance : int, float, None
            If None (default), no action tolerance is set or compared to.
            If an int or float, the MLC pair measurement is also compared to this
            tolerance. Must be lower than tolerance. This value is usually meant
            to indicate that a physicist should take an "action" to reduce the error,
            but should not stop treatment.
        hdmlc : bool
            If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed.
            If True, an HD (2.5/5mm leaves) Millennium is assumed.
        num_pickets : int, None

            .. versionadded:: 0.8

            The number of pickets in the image. A helper parameter to limit the total number of pickets,
            only needed if analysis is catching things that aren't pickets.
        sag_adjustment : float, int

            .. versionadded:: 0.8

            The amount of shift in mm to apply to the image to correct for EPID sag.
            For Up-Down picket images, positive moves the image down, negative up.
            For Left-Right picket images, positive moves the image left, negative right.
        """
        if action_tolerance is not None and tolerance < action_tolerance:
            raise ValueError("Tolerance cannot be lower than the action tolerance")

        """Pre-analysis"""
        self.settings = Settings(self.orientation, tolerance, action_tolerance, hdmlc, self.image.dpmm)
        self._adjust_for_sag(sag_adjustment)

        """Analysis"""
        self.pickets = PicketHandler(self.image, self.settings, num_pickets)

    def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, show=True):
        """Plot the analyzed image.

        Parameters
        ----------
        guard_rails : bool
            Do/don't plot the picket "guard rails".
        mlc_peaks : bool
            Do/don't plot the MLC positions.
        overlay : bool
            Do/don't plot the alpha overlay of the leaf status.
        """
        # plot the image
        plt.clf()
        ax = plt.imshow(self.image.array, cmap=plt.cm.Greys)

        # plot guard rails and mlc peaks as desired
        for p_num, picket in enumerate(self.pickets):
            if guard_rails:
                picket.add_guards_to_axes(ax.axes)
            if mlc_peaks:
                for idx, mlc_meas in enumerate(picket.mlc_meas):
                    mlc_meas.add_to_axes(ax.axes, width=1.5)
        # plot the overlay if desired.
        if overlay:
            o = Overlay(self.image, self.settings, self.pickets)
            o.add_to_axes(ax)

        plt.xlim([0, self.image.shape[1]])
        plt.ylim([0, self.image.shape[0]])
        plt.axis('off')

        if show:
            plt.show()

    def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, **kwargs):
        """Save the analyzed figure to a file."""
        self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, show=False)
        plt.savefig(filename, **kwargs)

    def return_results(self):
        """Return results of analysis. Use with print()."""
        pass_pct = self.percent_passing
        string = "Picket Fence Results: \n{:2.1f}% " \
                 "Passed\nMedian Error: {:2.3f}mm \n" \
                 "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error,
                                                                                                   self.max_error_picket,
                                                                                                  self.max_error_leaf)
        return string

    @property
    def orientation(self):
        """The orientation of the image, either Up-Down or Left-Right."""
        # replace any dead pixels with median value
        temp_image = self.image.array.copy()
        temp_image[temp_image < np.median(temp_image)] = np.median(temp_image)

        # find "range" of 80 to 90th percentiles
        row_sum = np.sum(temp_image, 0)
        col_sum = np.sum(temp_image, 1)
        row80, row90 = np.percentile(row_sum, [80, 90])
        col80, col90 = np.percentile(col_sum, [80, 90])
        row_range = row90 - row80
        col_range = col90 - col80

        # The true picket side will have a greater difference in
        # percentiles than will the non-picket size.
        if row_range < col_range:
            orientation = orientations['LR']
        else:
            orientation = orientations['UD']
        return orientation
Exemplo n.º 41
0
 def test_file(self):
     img = Image.load(tif_path)
     self.assertIsInstance(img, FileImage)
Exemplo n.º 42
0
 def test_nonsense(self):
     with self.assertRaises(TypeError):
         Image.load('blahblah')
Exemplo n.º 43
0
 def setUpClass(cls):
     cls.dcm = Image.load(dcm_path)
Exemplo n.º 44
0
class PicketFence:
    """A class used for analyzing EPID images where radiation strips have been formed by the
    MLCs. The strips are assumed to be parallel to one another and normal to the image edge;
    i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting
    for any angle.

    Attributes
    ----------
    pickets: :class:`~pylinac.picketfence.PicketHandler`
    image: :class:`~pylinac.core.image.Image`

    Examples
    --------
    Run the demo::
        >>> PicketFence().run_demo()

    Typical session:
        >>> img_path = r"C:/QA/June/PF.dcm"  # the EPID image
        >>> mypf = PicketFence(img_path)
        >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3)
        >>> print(mypf.return_results())
        >>> mypf.plot_analyzed_image()
    """
    def __init__(self, filename=None, filter=None):
        """
        Parameters
        ----------
        filename : str, None
            Name of the file as a string. If None, image must be loaded later.
        filter : int, None
            The median filter size to apply to the image upon load.
        """
        if filename is not None:
            self.load_image(filename, filter)

    @classmethod
    def from_url(cls, url, filter=None):
        """Instantiate from a URL.

        .. versionadded:: 0.7.1
        """
        obj = cls()
        obj.load_url(url, filter=filter)
        return obj

    def load_url(self, url, filter=None):
        """Load from a URL.

        .. versionadded:: 0.7.1
        """
        response = get_url(url)
        stream = BytesIO(response.content)
        self.load_image(stream, filter=filter)

    @property
    def passed(self):
        """Boolean specifying if all MLC positions were within tolerance."""
        return self.pickets.passed

    @property
    def percent_passing(self):
        """Return the percentage of MLC positions under tolerance."""
        num = 0
        num_pass = 0
        for picket in self.pickets:
            num += len(picket.error_array)
            num_pass += sum(picket.error_array < self.settings.tolerance)
        pct_pass = 100 * num_pass / num
        return pct_pass

    @property
    def max_error(self):
        """Return the maximum error found."""
        return max(picket.max_error for picket in self.pickets)

    @property
    def max_error_picket(self):
        """Return the picket number where the maximum error occured."""
        return np.argmax([picket.max_error for picket in self.pickets])

    @property
    def max_error_leaf(self):
        """Return the leaf that had the maximum error."""
        picket = self.pickets[self.max_error_picket]
        return np.argmax(picket.error_array)

    @property
    @lru_cache()
    def abs_median_error(self):
        """Return the median error found."""
        return np.median(np.hstack([picket.error_array for picket in self.pickets]))

    @property
    def num_pickets(self):
        """Return the number of pickets determined."""
        return len(self.pickets)

    @classmethod
    def from_demo_image(cls, filter=None):
        """Construct a PicketFence instance using the demo image.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_demo_image(filter=filter)
        return obj

    def load_demo_image(self, filter=None):
        """Load the demo image that is included with pylinac."""
        im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm')
        self.load_image(im_open_path, filter=filter)

    def load_image(self, file_path, filter=None):
        """Load the image

        Parameters
        ----------
        file_path : str
            Path to the image file.
        filter : int, None
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size *filter*.
        """
        self.image = Image(file_path)
        if isinstance(filter, int):
            self.image.median_filter(size=filter)
        self._check_for_noise()
        self.image.check_inversion()

    @classmethod
    def from_image_UI(cls, filter=None):
        """Construct a PicketFence instance and load an image using a dialog box.

        .. versionadded:: 0.6
        """
        obj = cls()
        obj.load_image_UI(filter=filter)
        return obj

    def load_image_UI(self, filter=None):
        """Load the image using a UI dialog box."""
        path = get_filepath_UI()
        self.load_image(path, filter=filter)

    @classmethod
    def from_multiple_images(cls, path_list):
        """Load and superimpose multiple images and instantiate a Starshot object.

        .. versionadded:: 0.9

        Parameters
        ----------
        path_list : iterable
            An iterable of path locations to the files to be loaded/combined.
        """
        obj = cls()
        obj.load_multiple_images(path_list)
        return obj

    def load_multiple_images(self, path_list):
        """Load and superimpose multiple images.

        .. versionadded:: 0.9

        Parameters
        ----------
        path_list : iterable
            An iterable of path locations to the files to be loaded/combined.
        """
        self.image = Image.from_multiples(path_list, method='mean')
        self._check_for_noise()
        self.image.check_inversion()

    def _check_for_noise(self):
        """Check if the image has extreme noise (dead pixel, etc) by comparing
        min/max to 1/99 percentiles and smoothing if need be."""
        while self._has_noise():
            self.image.median_filter()

    def _has_noise(self):
        """Helper method to determine if there is spurious signal in the image."""
        min = self.image.array.min()
        max = self.image.array.max()
        near_min, near_max = np.percentile(self.image.array, [0.5, 99.5])
        max_is_extreme = max > near_max * 2
        min_is_extreme = (min < near_min) and (abs(near_min - min) > 0.2 * near_max)
        return max_is_extreme or min_is_extreme

    def _adjust_for_sag(self, sag):
        """Roll the image to adjust for EPID sag."""
        sag_pixels = int(round(sag * self.settings.dpmm))
        direction = 'y' if self.orientation == orientations['UD'] else 'x'
        self.image.roll(direction, sag_pixels)

    def run_demo(self, tolerance=0.5, action_tolerance=0.25, interactive=False):
        """Run the Picket Fence demo using the demo image. See analyze() for parameter info."""
        self.load_demo_image()
        self.analyze(tolerance, action_tolerance=action_tolerance)
        print(self.return_results())
        self.plot_analyzed_image(interactive=interactive, leaf_error_subplot=True)

    def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False, num_pickets=None, sag_adjustment=0):
        """Analyze the picket fence image.

        Parameters
        ----------
        tolerance : int, float
            The tolerance of difference in mm between an MLC pair position and the
            picket fit line.
        action_tolerance : int, float, None
            If None (default), no action tolerance is set or compared to.
            If an int or float, the MLC pair measurement is also compared to this
            tolerance. Must be lower than tolerance. This value is usually meant
            to indicate that a physicist should take an "action" to reduce the error,
            but should not stop treatment.
        hdmlc : bool
            If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed.
            If True, an HD (2.5/5mm leaves) Millennium is assumed.
        num_pickets : int, None

            .. versionadded:: 0.8

            The number of pickets in the image. A helper parameter to limit the total number of pickets,
            only needed if analysis is catching more pickets than there really are.
        sag_adjustment : float, int

            .. versionadded:: 0.8

            The amount of shift in mm to apply to the image to correct for EPID sag.
            For Up-Down picket images, positive moves the image down, negative up.
            For Left-Right picket images, positive moves the image left, negative right.
        """
        if action_tolerance is not None and tolerance < action_tolerance:
            raise ValueError("Tolerance cannot be lower than the action tolerance")

        """Pre-analysis"""
        self.settings = Settings(self.orientation, tolerance, action_tolerance, hdmlc, self.image)
        self._adjust_for_sag(sag_adjustment)

        """Analysis"""
        self.pickets = PicketHandler(self.image, self.settings, num_pickets)

    def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, leaf_error_subplot=True, interactive=False, show=True):
        """Plot the analyzed image.

        Parameters
        ----------
        guard_rails : bool
            Do/don't plot the picket "guard rails" around the ideal picket
        mlc_peaks : bool
            Do/don't plot the MLC positions.
        overlay : bool
            Do/don't plot the alpha overlay of the leaf status.
        leaf_error_subplot : bool

            .. versionadded:: 1.0

            If True, plots a linked leaf error subplot adjacent to the PF image plotting the average and standard
            deviation of leaf error.
        interactive : bool

            .. versionadded:: 1.0
            .. note:: mpld3 must be installed to use this feature.

            If False (default), plots a matplotlib figure.
            If True, plots a MPLD3 local server image, which adds some tooltips.
        """
        # plot the image
        fig, ax = plt.subplots(figsize=self.settings.figure_size)
        ax.imshow(self.image.array, cmap=plt.cm.Greys)

        # generate a leaf error subplot if desired
        if leaf_error_subplot:
            self._add_leaf_error_subplot(ax, fig, interactive)

        # plot guard rails and mlc peaks as desired
        for p_num, picket in enumerate(self.pickets):
            if guard_rails:
                picket.add_guards_to_axes(ax.axes)
            if mlc_peaks:
                for idx, mlc_meas in enumerate(picket.mlc_meas):
                    mlc_meas.add_to_axes(ax.axes, width=1.5)

        # plot the overlay if desired.
        if overlay:
            o = Overlay(self.image, self.settings, self.pickets)
            o.add_to_axes(ax)

        # tighten up the plot view
        ax.set_xlim([0, self.image.shape[1]])
        ax.set_ylim([0, self.image.shape[0]])
        ax.axis('off')

        if show:
            if interactive:
                mpld3 = import_mpld3()
                mpld3.show()
            else:
                plt.show()

    def _add_leaf_error_subplot(self, ax, fig, interactive):
        """Add a bar subplot showing the leaf error."""
        tol_line_height = [self.settings.tolerance, self.settings.tolerance]
        tol_line_width = [0, max(self.image.shape)]

        # make the new axis
        divider = make_axes_locatable(ax)
        if self.settings.orientation == orientations['UD']:
            axtop = divider.append_axes('right', 2, pad=1, sharey=ax)
        else:
            axtop = divider.append_axes('bottom', 2, pad=1, sharex=ax)

        # get leaf positions, errors, standard deviation, and leaf numbers
        pos, vals, err, leaf_nums = self.pickets.error_hist()

        # plot the leaf errors as a bar plot
        if self.settings.orientation == orientations['UD']:
            axtop.barh(pos, vals, xerr=err, height=self.pickets[0].sample_width * 2, alpha=0.4, align='center')
            # plot the tolerance line(s)
            # TODO: replace .plot() calls with .axhline when mpld3 fixes funtionality
            axtop.plot(tol_line_height, tol_line_width, 'r-', linewidth=3)
            if self.settings.action_tolerance is not None:
                axtop.plot(tol_line_height, tol_line_width, 'y-', linewidth=3)

            # reset xlims to comfortably include the max error or tolerance value
            axtop.set_xlim([0, max(max(vals), self.settings.tolerance) + 0.1])
        else:
            axtop.bar(pos, vals, yerr=err, width=self.pickets[0].sample_width * 2, alpha=0.4, align='center')
            axtop.plot(tol_line_width, tol_line_height,
                       'r-', linewidth=3)
            if self.settings.action_tolerance is not None:
                axtop.plot(tol_line_width, tol_line_height, 'y-', linewidth=3)
            axtop.set_ylim([0, max(max(vals), self.settings.tolerance) + 0.1])

        # add formatting to axis
        axtop.grid('on')
        axtop.set_title("Average Error (mm)")

        # add tooltips if interactive
        if interactive:
            labels = [['Leaf pair {}/{}, Avg Error: {:3.3f}mm, Stdev: {:3.3f}mm'.format(leaf_num[0], leaf_num[1], err, std)]
                      for leaf_num, err, std in zip(leaf_nums, vals, err)]
            mpld3 = import_mpld3()
            for num, patch in enumerate(axtop.axes.patches):
                ttip = mpld3.plugins.PointLabelTooltip(patch, labels[num], location='top left')
                mpld3.plugins.connect(fig, ttip)

    def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, leaf_error_subplot=False, interactive=False, **kwargs):
        """Save the analyzed figure to a file. See :meth:`~pylinac.picketfence.PicketFence.plot_analyzed_image()` for
        further parameter info.

        interactive : bool
            If False (default), saves the figure as a .png image.
            If True, saves an html file, which can be opened in a browser, etc.

            .. note:: mpld3 must be installed to use this feature.
        """
        self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, leaf_error_subplot=leaf_error_subplot,
                                 interactive=interactive, show=False)
        if interactive:
            mpld3 = import_mpld3()
            mpld3.save_html(plt.gcf(), filename)
        else:
            plt.savefig(filename, **kwargs)
        if isinstance(filename, str):
            print("Picket fence image saved to: {}".format(osp.abspath(filename)))

    def return_results(self):
        """Return results of analysis. Use with print()."""
        pass_pct = self.percent_passing
        string = "Picket Fence Results: \n{:2.1f}% " \
                 "Passed\nMedian Error: {:2.3f}mm \n" \
                 "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error,
                                                                                                   self.max_error_picket,
                                                                                                  self.max_error_leaf)
        return string

    @property
    def orientation(self):
        """The orientation of the image, either Up-Down or Left-Right."""
        # replace any dead pixels with median value
        temp_image = self.image.array.copy()
        temp_image[temp_image < np.median(temp_image)] = np.median(temp_image)

        # find "range" of 80 to 90th percentiles
        row_sum = np.sum(temp_image, 0)
        col_sum = np.sum(temp_image, 1)
        row80, row90 = np.percentile(row_sum, [80, 90])
        col80, col90 = np.percentile(col_sum, [80, 90])
        row_range = row90 - row80
        col_range = col90 - col80

        # The true picket side will have a greater difference in
        # percentiles than will the non-picket size.
        if row_range < col_range:
            orientation = orientations['LR']
        else:
            orientation = orientations['UD']
        return orientation