class LeedsTOR(ImagePhantomBase):
    _demo_filename = 'leeds.dcm'
    common_name = 'Leeds'
    phantom_bbox_size_mm2 = 148**2
    _is_ccw = False
    phantom_outline_object = {'Circle': {'radius ratio': 0.97}}
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 0.3, 'angle': 54.8, 'roi radius': 0.04, 'lp/mm': 0.5},
        'roi 2': {'distance from center': 0.187, 'angle': 25.1, 'roi radius': 0.04, 'lp/mm': 0.56},
        'roi 3': {'distance from center': 0.187, 'angle': -27.5, 'roi radius': 0.04, 'lp/mm': 0.63},
        'roi 4': {'distance from center': 0.252, 'angle': 79.7, 'roi radius': 0.03, 'lp/mm': 0.71},
        'roi 5': {'distance from center': 0.092, 'angle': 63.4, 'roi radius': 0.03, 'lp/mm': 0.8},
        'roi 6': {'distance from center': 0.094, 'angle': -65, 'roi radius': 0.02, 'lp/mm': 0.9},
        'roi 7': {'distance from center': 0.252, 'angle': -263, 'roi radius': 0.02, 'lp/mm': 1.0},
        'roi 8': {'distance from center': 0.094, 'angle': -246, 'roi radius': 0.018, 'lp/mm': 1.12},
        'roi 9': {'distance from center': 0.0958, 'angle': -117, 'roi radius': 0.018, 'lp/mm': 1.25},
        'roi 10': {'distance from center': 0.27, 'angle': 112.5, 'roi radius': 0.015, 'lp/mm': 1.4},
        'roi 11': {'distance from center': 0.13, 'angle': 145, 'roi radius': 0.015, 'lp/mm': 1.6},
        'roi 12': {'distance from center': 0.135, 'angle': -142, 'roi radius': 0.011, 'lp/mm': 1.8},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.65, 'angle': 30, 'roi radius': 0.025},
        'roi 2': {'distance from center': 0.65, 'angle': 120, 'roi radius': 0.025},
        'roi 3': {'distance from center': 0.65, 'angle': 210, 'roi radius': 0.025},
        'roi 4': {'distance from center': 0.65, 'angle': 300, 'roi radius': 0.025},
    }
    low_contrast_roi_settings = {
        # set 1
        'roi 1': {'distance from center': 0.785, 'angle': 30, 'roi radius': 0.025},
        'roi 2': {'distance from center': 0.785, 'angle': 45, 'roi radius': 0.025},
        'roi 3': {'distance from center': 0.785, 'angle': 60, 'roi radius': 0.025},
        'roi 4': {'distance from center': 0.785, 'angle': 75, 'roi radius': 0.025},
        'roi 5': {'distance from center': 0.785, 'angle': 90, 'roi radius': 0.025},
        'roi 6': {'distance from center': 0.785, 'angle': 105, 'roi radius': 0.025},
        'roi 7': {'distance from center': 0.785, 'angle': 120, 'roi radius': 0.025},
        'roi 8': {'distance from center': 0.785, 'angle': 135, 'roi radius': 0.025},
        'roi 9': {'distance from center': 0.785, 'angle': 150, 'roi radius': 0.025},
        # set 2
        'roi 10': {'distance from center': 0.785, 'angle': 210, 'roi radius': 0.025},
        'roi 11': {'distance from center': 0.785, 'angle': 225, 'roi radius': 0.025},
        'roi 12': {'distance from center': 0.785, 'angle': 240, 'roi radius': 0.025},
        'roi 13': {'distance from center': 0.785, 'angle': 255, 'roi radius': 0.025},
        'roi 14': {'distance from center': 0.785, 'angle': 270, 'roi radius': 0.025},
        'roi 15': {'distance from center': 0.785, 'angle': 285, 'roi radius': 0.025},
        'roi 16': {'distance from center': 0.785, 'angle': 300, 'roi radius': 0.025},
        'roi 17': {'distance from center': 0.785, 'angle': 315, 'roi radius': 0.025},
        'roi 18': {'distance from center': 0.785, 'angle': 330, 'roi radius': 0.025},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Leeds TOR phantom analysis demonstration."""
        leeds = LeedsTOR.from_demo_image()
        leeds.analyze()
        leeds.plot_analyzed_image()

    @lru_cache()
    def _phantom_angle_calc(self) -> float:
        """Determine the angle of the phantom.

        This is done by searching for square-like boxes of the canny image. There are usually two: one lead and
        one copper. The box with the highest intensity (lead) is identified. The angle from the center of the lead
        square bounding box and the phantom center determines the phantom angle.

        Returns
        -------
        angle : float
            The angle in degrees
        """
        start_angle_deg = self._determine_start_angle_for_circle_profile()
        circle = self._circle_profile_for_phantom_angle(start_angle_deg, is_ccw=True)
        peak_idx, _ = circle.find_fwxm_peaks(threshold=0.6, max_number=1)

        shift_percent = peak_idx[0] / len(circle.values)
        shift_radians = shift_percent * 2 * np.pi
        shift_radians_corrected = 2*np.pi - shift_radians

        angle = np.degrees(shift_radians_corrected) + start_angle_deg
        return angle

    def _phantom_radius_calc(self) -> float:
        """Determine the radius of the phantom.

        The radius is determined by finding the largest of the detected blobs of the canny image and taking
        its major axis length.

        Returns
        -------
        radius : float
            The radius of the phantom in pixels. The actual value is not important; it is used for scaling the
            distances to the low and high contrast ROIs.
        """
        return self.phantom_ski_region.major_axis_length / 2.73

    def _determine_start_angle_for_circle_profile(self) -> float:
        """Determine an appropriate angle for starting the circular profile
        used to determine the phantom angle.

        In most cases we can just use 0 degs but for the case where the phantom
        is set up near 0 degs, the peak of the circular profile will be split
        between the left and right sides of the profile.  We can check for this
        case by looking at a few of the peak indexes and determining whether
        they are all on the left or right side of the profile or split left and
        right.  If they're split left and right, then we we need to use a
        different circular profile start angle  to get an accurate angle
        determination

        Returns
        -------
        start_angle_deg: float
            The start angle to be used for the circular profile used to determine the
            phantom rotation.
        """

        circle = self._circle_profile_for_phantom_angle(0)
        peak_idxs, _ = circle.find_fwxm_peaks(threshold=0.6, max_number=4)
        on_left_half = [x < len(circle.values) / 2 for x in peak_idxs]
        aligned_to_zero_deg = not(all(on_left_half) or not any(on_left_half))
        return 90 if aligned_to_zero_deg else 0

    def _preprocess(self) -> None:
        self._check_if_counter_clockwise()

    def _check_if_counter_clockwise(self) -> None:
        """Determine if the low-contrast bubbles go from high to low clockwise or counter-clockwise."""
        circle = self._circle_profile_for_phantom_angle(0)
        peak_idx, _ = circle.find_fwxm_peaks(threshold=0.6, max_number=1)
        circle.values = np.roll(circle.values, -peak_idx[0])
        _, first_set = circle.find_peaks(search_region=(0.05, 0.45), threshold=0, min_distance=0.025, max_number=9)
        _, second_set = circle.find_peaks(search_region=(0.55, 0.95), threshold=0, min_distance=0.025, max_number=9)
        self._is_ccw = max(first_set) > max(second_set)
        if not self._is_ccw:
            self.image.fliplr()
            del self.phantom_ski_region  # clear the property to calculate it again since we flipped it

    def _circle_profile_for_phantom_angle(self, start_angle_deg: float, is_ccw: bool = False) -> CollapsedCircleProfile:
        """Create a circular profile centered at phantom origin

        Parameters
        ----------
        start_angle_deg: float

            Angle in degrees at which to start the profile
        Returns
        -------
        circle : CollapsedCircleProfile
            The circular profile centered on the phantom center and origin set to the given start angle.
        """
        circle = CollapsedCircleProfile(
            self.phantom_center,
            self.phantom_radius * 0.79,
            self.image.array,
            width_ratio=0.04,
            ccw=is_ccw,
            start_angle=np.deg2rad(start_angle_deg),
        )
        circle.ground()
        circle.filter(size=0.01)
        circle.invert()
        return circle class StandardImagingQC3(ImagePhantomBase):
    _demo_filename = 'qc3.dcm'
    common_name = 'SI QC-3'
    phantom_bbox_size_mm2 = 168**2
    detection_conditions = [is_centered, is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 7.5, 'height ratio': 6}}
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 2.8, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.1},
        'roi 2': {'distance from center': -2.8, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.2},
        'roi 3': {'distance from center': 1.45, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.25},
        'roi 4': {'distance from center': -1.45, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.45},
        'roi 5': {'distance from center': 0, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.76},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 2, 'angle': -90, 'roi radius': 0.5},
        'roi 2': {'distance from center': 2.4, 'angle': 55, 'roi radius': 0.5},
        'roi 3': {'distance from center': 2.4, 'angle': -55, 'roi radius': 0.5},
        'roi 4': {'distance from center': 2.4, 'angle': 128, 'roi radius': 0.5},
        'roi 5': {'distance from center': 2.4, 'angle': -128, 'roi radius': 0.5},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 2, 'angle': 90, 'roi radius': 0.5},
    }

    @classmethod
    def from_demo_image(cls):
        """Instantiate and load the demo image."""
        demo_file = retrieve_demo_file(url=cls._demo_filename)
        inst = cls(demo_file)
        inst.image.invert()
        return inst

    @staticmethod
    def run_demo() -> None:
        """Run the Standard Imaging QC-3 phantom analysis demonstration."""
        qc3 = StandardImagingQC3.from_demo_image()
        qc3.analyze()
        qc3.plot_analyzed_image()

    def _phantom_radius_calc(self) -> float:
        """The radius of the phantom in pixels; the value itself doesn't matter, it's just
        used for relative distances to ROIs.

        Returns
        -------
        radius : float
        """
        return self.phantom_ski_region.major_axis_length / 14

    @lru_cache()
    def _phantom_angle_calc(self) -> float:
        """The angle of the phantom. This assumes the user is using the stand that comes with the phantom,
        which angles the phantom at 45 degrees.

        Returns
        -------
        angle : float
            The angle in degrees.
        """
        angle = np.degrees(self.phantom_ski_region.orientation)
        if np.isclose(angle, 45, atol=5):
            return 45
        elif np.isclose(angle, -45, atol=5):
            return -45
        else:
            raise ValueError("The phantom angle was not near +/-45 degrees. Please adjust the phantom.") class LasVegas(ImagePhantomBase):
    _demo_filename = 'lasvegas.dcm'
    common_name = 'Las Vegas'
    phantom_bbox_size_mm2 = 20260
    detection_conditions = [is_centered, is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 0.62, 'height ratio': 0.62}}
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.107, 'angle': 0.5, 'roi radius': 0.020},
    }
    low_contrast_roi_settings = {
        'roi 2': {'distance from center': 0.141, 'angle': 39.5, 'roi radius': 0.028},
        'roi 3': {'distance from center': 0.205, 'angle': 58, 'roi radius': 0.028},
        'roi 4': {'distance from center': 0.179, 'angle': -76.5, 'roi radius': 0.016},
        'roi 5': {'distance from center': 0.095, 'angle': -63.5, 'roi radius': 0.016},
        'roi 6': {'distance from center': 0.042, 'angle': 0.5, 'roi radius': 0.016},
        'roi 7': {'distance from center': 0.097, 'angle': 65.5, 'roi radius': 0.016},
        'roi 8': {'distance from center': 0.178, 'angle': 76.5, 'roi radius': 0.016},
        'roi 9': {'distance from center': 0.174, 'angle': -97.5, 'roi radius': 0.012},
        'roi 10': {'distance from center': 0.088, 'angle': -105.5, 'roi radius': 0.012},
        'roi 11': {'distance from center': 0.024, 'angle': -183.5, 'roi radius': 0.012},
        'roi 12': {'distance from center': 0.091, 'angle': 105.5, 'roi radius': 0.012},
        'roi 13': {'distance from center': 0.179, 'angle': 97.5, 'roi radius': 0.012},
        'roi 14': {'distance from center': 0.189, 'angle': -113.5, 'roi radius': 0.007},
        'roi 15': {'distance from center': 0.113, 'angle': -131.5, 'roi radius': 0.007},
        'roi 16': {'distance from center': 0.0745, 'angle': -181.5, 'roi radius': 0.007},
        'roi 17': {'distance from center': 0.115, 'angle': 130, 'roi radius': 0.007},
        'roi 18': {'distance from center': 0.191, 'angle': 113, 'roi radius': 0.007},
        'roi 19': {'distance from center': 0.2085, 'angle': -124.6, 'roi radius': 0.003},
        'roi 20': {'distance from center': 0.146, 'angle': -144.3, 'roi radius': 0.003},
    }

    @staticmethod
    def run_demo():
        """Run the Las Vegas phantom analysis demonstration."""
        lv = LasVegas.from_demo_image()
        lv.analyze()
        lv.plot_analyzed_image()

    def _preprocess(self):
        self._check_direction()

    def _check_inversion(self):
        """Check the inversion by using the histogram of the phantom region"""
        roi = self.phantom_ski_region
        phantom_array = self.image.array[roi.bbox[0]:roi.bbox[2], roi.bbox[1]:roi.bbox[3]]
        phantom_sub_image = image.load(phantom_array)
        phantom_sub_image.crop(int(phantom_sub_image.shape[0]*0.1))
        p5 = np.percentile(phantom_sub_image, 0.5)
        p50 = np.percentile(phantom_sub_image, 50)
        p95 = np.percentile(phantom_sub_image, 99.5)
        dist_to_5 = abs(p50 - p5)
        dist_to_95 = abs(p50 - p95)
        if dist_to_5 > dist_to_95:
            self.image.invert()

    def _check_direction(self) -> None:
        """Check that the phantom is facing the right direction and if not perform a left-right flip of the array."""
        circle = CollapsedCircleProfile(self.phantom_center, self.phantom_radius * 0.175, self.image, ccw=False,
                                        width_ratio=0.16, num_profiles=5)
        roll_amount = np.where(circle.values == circle.values.min())[0][0]
        circle.roll(roll_amount)
        circle.filter(size=0.015, kind='median')
        valley_idxs, _ = circle.find_peaks(max_number=2)
        if valley_idxs[0] > valley_idxs[1]:
            self.image.array = np.fliplr(self.image.array)
            self._phantom_ski_region = None

    def _phantom_radius_calc(self) -> float:
        return self.phantom_ski_region.major_axis_length

    def _phantom_angle_calc(self) -> float:
        return 0.0 class DoselabMC2kV(ImagePhantomBase):
    common_name = "Doselab MC2 kV"
    _demo_filename = 'Doselab_kV.dcm'
    phantom_bbox_size_mm2 = 26300
    detection_conditions = [is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 0.55, 'height ratio': 0.63}}
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.27, 'angle': 48.5, 'roi radius': 0.025},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 0.27, 'angle': -48.5, 'roi radius': 0.025},
        'roi 2': {'distance from center': 0.225, 'angle': -65, 'roi radius': 0.025},
        'roi 3': {'distance from center': 0.205, 'angle': -88.5, 'roi radius': 0.025},
        'roi 4': {'distance from center': 0.22, 'angle': -110, 'roi radius': 0.025},
        'roi 5': {'distance from center': 0.22, 'angle': 110, 'roi radius': 0.025},
        'roi 6': {'distance from center': 0.205, 'angle': 88.5, 'roi radius': 0.025},
        'roi 7': {'distance from center': 0.225, 'angle': 65, 'roi radius': 0.025},
    }
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 0.17, 'angle': -20, 'roi radius': 0.013, 'lp/mm': 0.6},
        'roi 2': {'distance from center': 0.16, 'angle': -2, 'roi radius': 0.007, 'lp/mm': 1.2},
        'roi 3': {'distance from center': 0.164, 'angle': 12.8, 'roi radius': 0.005, 'lp/mm': 1.8},
        'roi 4': {'distance from center': 0.175, 'angle': 24.7, 'roi radius': 0.0035, 'lp/mm': 2.4},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Doselab MC2 kV-area phantom analysis demonstration."""
        leeds = DoselabMC2kV.from_demo_image()
        leeds.analyze()
        leeds.plot_analyzed_image()

    def _phantom_radius_calc(self) -> float:
        return self.phantom_ski_region.major_axis_length

    def _phantom_angle_calc(self) -> float:
        roi = self.phantom_ski_region
        angle = np.degrees(roi.orientation) + 90
        if not np.isclose(angle, 45, atol=5):
            raise ValueError("Angles not close enough to the ideal 45 degrees. Check phantom setup or override angle.")
        return angle class DoselabMC2MV(DoselabMC2kV):
    common_name = 'Doselab MC2 MV'
    _demo_filename = 'Doselab_MV.dcm'
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.27, 'angle': 48.5, 'roi radius': 0.025},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 0.27, 'angle': -48.5, 'roi radius': 0.025},
        'roi 2': {'distance from center': 0.225, 'angle': -65, 'roi radius': 0.025},
        'roi 3': {'distance from center': 0.205, 'angle': -88.5, 'roi radius': 0.025},
        'roi 4': {'distance from center': 0.22, 'angle': -110, 'roi radius': 0.025},
        'roi 5': {'distance from center': 0.22, 'angle': 110, 'roi radius': 0.025},
        'roi 6': {'distance from center': 0.205, 'angle': 88.5, 'roi radius': 0.025},
        'roi 7': {'distance from center': 0.225, 'angle': 65, 'roi radius': 0.025},
    }
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 0.23, 'angle': -135.3, 'roi radius': 0.012, 'lp/mm': 0.1},
        'roi 2': {'distance from center': 0.173, 'angle': 161, 'roi radius': 0.012, 'lp/mm': 0.2},
        'roi 3': {'distance from center': 0.237, 'angle': 133, 'roi radius': 0.012, 'lp/mm': 0.4},
        'roi 4': {'distance from center': 0.298, 'angle': 122.9, 'roi radius': 0.01, 'lp/mm': 0.8},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Doselab MC2 MV-area phantom analysis demonstration."""
        leeds = DoselabMC2MV.from_demo_image()
        leeds.analyze()
        leeds.plot_analyzed_image() class PlanarResult(ResultBase):
    """This class should not be called directly. It is returned by the ``results_data()`` method.
    It is a dataclass under the hood and thus comes with all the dunder magic.

    Use the following attributes as normal class attributes."""
    analysis_type: str  #:
    median_contrast: float  #:
    median_cnr: float  #:
    num_contrast_rois_seen: int  #:
    phantom_center_x_y: Tuple[float, float]  #:
    mtf_lp_mm: Tuple[float, float, float] = None  #: class SNCkV(ImagePhantomBase):
    _demo_filename = 'SNC-kV.dcm'
    common_name = 'SNC kV-QA'
    phantom_bbox_size_mm2 = 134 ** 2
    detection_conditions = [is_centered, is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 7.7, 'height ratio': 5.6}}
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 1.8, 'angle': 0, 'roi radius': 0.7, 'lp/mm': 0.6},
        'roi 2': {'distance from center': -1.8, 'angle': 90, 'roi radius': 0.7, 'lp/mm': 1.2},
        'roi 3': {'distance from center': -1.8, 'angle': 0, 'roi radius': 0.7, 'lp/mm': 1.8},
        'roi 4': {'distance from center': 1.8, 'angle': 90, 'roi radius': 0.7, 'lp/mm': 2.4},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 2.6, 'angle': -45, 'roi radius': 0.6},
        'roi 2': {'distance from center': 2.6, 'angle': -135, 'roi radius': 0.6},
        'roi 3': {'distance from center': 2.6, 'angle': 45, 'roi radius': 0.6},
        'roi 4': {'distance from center': 2.6, 'angle': 135, 'roi radius': 0.6},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.5, 'angle': 90, 'roi radius': 0.25},
        'roi 2': {'distance from center': 0.5, 'angle': -90, 'roi radius': 0.25},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Sun Nuclear kV-QA phantom analysis demonstration."""
        snc = SNCkV.from_demo_image()
        snc.analyze()
        snc.plot_analyzed_image()

    def _phantom_radius_calc(self) -> float:
        """The radius of the phantom in pixels; the value itself doesn't matter, it's just
        used for relative distances to ROIs.

        Returns
        -------
        radius : float
        """
        return self.phantom_ski_region.major_axis_length / 12

    def _phantom_angle_calc(self) -> float:
        """The angle of the phantom. This assumes the user is using the stand that comes with the phantom,
        which angles the phantom at 45 degrees.

        Returns
        -------
        angle : float
            The angle in degrees.
        """
        return 135 class SNCMV(SNCkV):
    _demo_filename = 'SNC-MV.dcm'
    common_name = 'SNC MV-QA'
    phantom_bbox_size_mm2 = 118 ** 2
    detection_conditions = [is_centered, is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 7.5, 'height ratio': 7.5}}
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': -2.3, 'angle': 0, 'roi radius': 0.8, 'lp/mm': 0.1},
        'roi 2': {'distance from center': 2.3, 'angle': 90, 'roi radius': 0.8, 'lp/mm': 0.2},
        'roi 3': {'distance from center': 2.3, 'angle': 0, 'roi radius': 0.8, 'lp/mm': 0.5},
        'roi 4': {'distance from center': -2.3, 'angle': 90, 'roi radius': 0.8, 'lp/mm': 1.0},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 3.4, 'angle': -45, 'roi radius': 0.7},
        'roi 2': {'distance from center': 3.4, 'angle': 45, 'roi radius': 0.7},
        'roi 3': {'distance from center': 3.4, 'angle': 135, 'roi radius': 0.7},
        'roi 4': {'distance from center': 3.4, 'angle': -135, 'roi radius': 0.7},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 0.7, 'angle': 0, 'roi radius': 0.2},
        'roi 2': {'distance from center': -0.7, 'angle': 0, 'roi radius': 0.2},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Sun Nuclear MV-QA phantom analysis demonstration."""
        snc = SNCMV.from_demo_image()
        snc.analyze()
        snc.plot_analyzed_image()

    def _phantom_angle_calc(self) -> float:
        """The angle of the phantom. This assumes the user is using the stand that comes with the phantom,
        which angles the phantom at 45 degrees.

        Returns
        -------
        angle : float
            The angle in degrees.
        """
        return 45 class StandardImagingQCkV(StandardImagingQC3):
    _demo_filename = 'SI-QC-kV.dcm'
    common_name = 'SI QC-kV'
    phantom_bbox_size_mm2 = 142**2
    detection_conditions = [is_centered, is_right_size]
    phantom_outline_object = {'Rectangle': {'width ratio': 7.8, 'height ratio': 6.4}}
    high_contrast_roi_settings = {
        'roi 1': {'distance from center': 2.8, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.66},
        'roi 2': {'distance from center': -2.8, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 0.98},
        'roi 3': {'distance from center': 1.45, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 1.50},
        'roi 4': {'distance from center': -1.45, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 2.00},
        'roi 5': {'distance from center': 0, 'angle': 0, 'roi radius': 0.5, 'lp/mm': 2.46},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 2, 'angle': 90, 'roi radius': 0.5},
        'roi 2': {'distance from center': 2, 'angle': -90, 'roi radius': 0.5},
        'roi 3': {'distance from center': 2.4, 'angle': 55, 'roi radius': 0.5},
        'roi 4': {'distance from center': 2.4, 'angle': -55, 'roi radius': 0.5},
        'roi 5': {'distance from center': 2.4, 'angle': 128, 'roi radius': 0.5},
        'roi 6': {'distance from center': 2.4, 'angle': -128, 'roi radius': 0.5},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 2, 'angle': 90, 'roi radius': 0.5},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Standard Imaging QC-3 phantom analysis demonstration."""
        qc3 = StandardImagingQCkV.from_demo_image()
        qc3.analyze()
        qc3.plot_analyzed_image()

    def _phantom_radius_calc(self) -> float:
        """The radius of the phantom in pixels; the value itself doesn't matter, it's just
        used for relative distances to ROIs.

        Returns
        -------
        radius : float
        """
        return self.phantom_ski_region.major_axis_length / 12.5 class PTWEPIDQC(ImagePhantomBase):
    _demo_filename = 'PTW-EPID-QC.dcm'
    common_name = 'PTW EPID QC'
    phantom_bbox_size_mm2 = 250**2
    detection_conditions = [is_centered, is_right_size]
    detection_canny_settings = {'sigma': 4, 'percentiles': (0.001, 0.01)}
    phantom_outline_object = {'Rectangle': {'width ratio': 8.55, 'height ratio': 8.55}}
    high_contrast_roi_settings = {
        # angled rois
        'roi 1': {'distance from center': 1.5, 'angle': -135, 'roi radius': 0.35, 'lp/mm': 0.15},
        'roi 2': {'distance from center': 3.1, 'angle': -109, 'roi radius': 0.35, 'lp/mm': 0.21},
        'roi 3': {'distance from center': 3.2, 'angle': -60, 'roi radius': 0.3, 'lp/mm': 0.27},
        'roi 4': {'distance from center': 1.9, 'angle': -60, 'roi radius': 0.25, 'lp/mm': 0.33},
        # vertical rois
        'roi 5': {'distance from center': 3.6, 'angle': -90, 'roi radius': 0.18, 'lp/mm': 0.5},
        'roi 6': {'distance from center': 2.9, 'angle': -90, 'roi radius': 0.08, 'lp/mm': 2},
        'roi 7': {'distance from center': 2.2, 'angle': -90, 'roi radius': 0.04, 'lp/mm': 3},
    }
    low_contrast_roi_settings = {
        'roi 1': {'distance from center': 3.87, 'angle': 31, 'roi radius': 0.3},
        'roi 2': {'distance from center': 3.48, 'angle': 17, 'roi radius': 0.3},
        'roi 3': {'distance from center': 3.3, 'angle': 0, 'roi radius': 0.3},
        'roi 4': {'distance from center': 3.48, 'angle': -17, 'roi radius': 0.3},
        'roi 5': {'distance from center': 3.87, 'angle': -31, 'roi radius': 0.3},
        'roi 6': {'distance from center': 3.87, 'angle': 180-31, 'roi radius': 0.3},
        'roi 7': {'distance from center': 3.48, 'angle': 180 - 17, 'roi radius': 0.3},
        'roi 8': {'distance from center': 3.3, 'angle': 180, 'roi radius': 0.3},
        'roi 9': {'distance from center': 3.48, 'angle': 180 + 17, 'roi radius': 0.3},
    }
    low_contrast_background_roi_settings = {
        'roi 1': {'distance from center': 3.85, 'angle': -148, 'roi radius': 0.3},
    }

    @staticmethod
    def run_demo() -> None:
        """Run the Standard Imaging QC-3 phantom analysis demonstration."""
        ptw = PTWEPIDQC.from_demo_image()
        ptw.analyze()
        ptw.plot_analyzed_image()

    def _phantom_radius_calc(self) -> float:
        """The radius of the phantom in pixels; the value itself doesn't matter, it's just
        used for relative distances to ROIs.

        Returns
        -------
        radius : float
        """
        return self.phantom_ski_region.major_axis_length / 14

    def _phantom_angle_calc(self) -> float:
        """The angle of the phantom. This assumes the user is using the stand that comes with the phantom,
        which angles the phantom at 45 degrees.

        Returns
        -------
        angle : float
            The angle in degrees.
        """
        return 0 def save_file(method, *args, as_file_object=None, **kwargs):
    """Save a file using the passed method and assert it exists after saving.
    Also deletes the file after checking for existence."""
    if as_file_object is None:  # regular file
        with TemporaryDirectory() as tmpdir:
            tmpfile = osp.join(tmpdir, "myfile")
            method(tmpfile, *args, **kwargs)
    else:
        if "b" in as_file_object:
            temp = BytesIO
        elif "s" in as_file_object:
            temp = StringIO
        with temp() as t:
            method(t, *args, **kwargs) class CloudFileMixin:
    """A mixin that provides attrs and a method to get the absolute location of the
    file using syntax.

    Usage
    -----
    0. Override ``dir_location`` with the directory of the destination.
    1. Override ``file_path`` with a list that contains the subfolder(s) and file name.
    """

    file_name: str
    dir_path: Sequence[str]
    delete_file = True

    @classmethod
    def get_filename(cls) -> str:
        """Return the canonical path to the file on disk. Download if it doesn't exist."""
        full_path = Path(LOCAL_TEST_DIR, *cls.dir_path, cls.file_name).absolute()
        if full_path.is_file():
            return str(full_path)
        else:
            return get_file_from_cloud_test_repo([*cls.dir_path, cls.file_name])

    @classmethod
    def tearDownClass(cls):
        if cls.delete_file and DELETE_FILES:
            file = cls.get_filename()
            if osp.isfile(file):
                os.remove(file)
            elif osp.isdir(file):
                shutil.rmtree(file) def get_file_from_cloud_test_repo(path: List[str], force: bool = False) -> str:
    """Get a single file from GCP storage. Returns the path to disk it was downloaded to"""
    local_filename = osp.join(osp.dirname(__file__), LOCAL_TEST_DIR, *path)
    if osp.isfile(local_filename) and not force:
        return local_filename
    with access_gcp() as client:
        bucket = client.bucket(GCP_BUCKET_NAME)
        blob = bucket.blob(
            str(PurePosixPath(*path))
        )  # posix because google storage is on unix and won't find path w/ windows path
        # make any necessary subdirs leading up to the file
        if len(path) > 1:
            for idx in range(1, len(path)):
                local_dir = osp.join(osp.dirname(__file__), LOCAL_TEST_DIR, *path[:idx])
                if not osp.isdir(local_dir):
                    os.mkdir(local_dir)

        blob.download_to_filename(local_filename)
        return local_filename
import io
import os.path as osp
import unittest
import matplotlib.pyplot as plt
import numpy as np
from typing import Callable
from unittest import TestCase
from pylinac import LeedsTOR, StandardImagingQC3, LasVegas, DoselabMC2kV, DoselabMC2MV
from pylinac.planar_imaging import PlanarResult, SNCkV, SNCMV, StandardImagingQCkV, PTWEPIDQC
from tests_basic.utils import save_file, CloudFileMixin, get_file_from_cloud_test_repo
    def test_plotting(self):
        self.instance.plot_analyzed_image()
        self.instance.plot_analyzed_image(low_contrast=False, high_contrast=False)
        self.instance.plot_analyzed_image(image=False, low_contrast=False, high_contrast=False)

    def test_saving(self):
        self.instance.plot_analyzed_image()
        save_file(self.instance.save_analyzed_image)

    def test_pdf(self):
        save_file(self.instance.publish_pdf)

    def test_mtf(self):
        if self.mtf_50 is not None:
            self.assertAlmostEqual(self.mtf_50, self.instance.mtf.relative_resolution(50), delta=0.3)

    def test_results(self):
        self.assertIsInstance(self.instance.results(), str)


class LeedsDemo(PlanarPhantomMixin, TestCase):
    klass = LeedsTOR
    mtf_50 = 1.5

    def test_demo(self):
        LeedsTOR.run_demo()  # shouldn't raise


class LeedsCCW(PlanarPhantomMixin, TestCase):
    klass = LeedsTOR
    mtf_50 = 1.5
    file_name = 'Leeds_ccw.dcm'


class Leeds45Deg(PlanarPhantomMixin, TestCase):
    klass = LeedsTOR
    invert = True  # inverted in v3.0 due to changed default inversion behavior
    mtf_50 = 1.9
    ssd = 1500
    file_name = 'Leeds-45deg.dcm'


class LeedsDirtyEdges(PlanarPhantomMixin, TestCase):
    klass = LeedsTOR
    mtf_50 = 1.3
    ssd = 1000
    file_name = 'Leeds-dirty-edges.dcm'


@unittest.skip("Phantom appears distorted. MTF locations are different than other phantoms")
class LeedsClosedBlades(PlanarPhantomMixin, TestCase):
    klass = LeedsTOR
    mtf_50 = 1.3
    ssd = 1500
    file_name = 'Leeds-closed-blades.dcm'


class SIQC3Demo(PlanarPhantomMixin, TestCase):
    klass = StandardImagingQC3
    mtf_50 = 0.53

    def test_demo(self):
        StandardImagingQC3.run_demo()  # shouldn't raise


class SIQC3_1(PlanarPhantomMixin, TestCase):
    klass = StandardImagingQC3
    file_name = 'QC3-2.5MV.dcm'
    mtf_50 = 1.19


class SIQC3_2(PlanarPhantomMixin, TestCase):
    klass = StandardImagingQC3
    file_name = 'QC3-2.5MV-2.dcm'
    mtf_50 = 1.16

    def test_wrong_ssd_fails(self):
        self.instance = self.klass(self.get_filename())
        with self.assertRaises(ValueError):
            self.instance.analyze(ssd=1500)  # really at 1000


class LasVegasTestMixin(PlanarPhantomMixin):
    klass = LasVegas
    phantom_angle = 0

    def test_plotting(self):
        self.instance.plot_analyzed_image()
        self.instance.plot_analyzed_image(low_contrast=False)
        self.instance.plot_analyzed_image(image=False, low_contrast=False)

    def test_angle(self):
        self.assertAlmostEqual(self.instance.phantom_angle, self.phantom_angle, delta=1)


class LasVegasDemo(PlanarPhantomMixin, TestCase):
    klass = LasVegas
    phantom_angle = 0

    def test_demo(self):
        LasVegas.run_demo()  # shouldn't raise


class DoselabMVDemo(PlanarPhantomMixin, TestCase):
    klass = DoselabMC2MV
    mtf_50 = 0.54

    def test_demo(self):
        DoselabMC2MV.run_demo()


class DoselabkVDemo(PlanarPhantomMixin, TestCase):
    klass = DoselabMC2kV
    mtf_50 = 2.16

    def test_demo(self):
        DoselabMC2kV.run_demo()


class SNCkVDemo(PlanarPhantomMixin, TestCase):

    klass = SNCkV