class CatPhan504(CatPhanBase):
    """A class for loading and analyzing CT DICOM files of a CatPhan 504. Can be from a CBCT or CT scanner
    Analyzes: Uniformity (CTP486), High-Contrast Spatial Resolution (CTP528),
    Image Scaling & HU Linearity (CTP404), and Low contrast (CTP515).
    """
    _demo_url = 'CatPhan504.zip'
    _model = '504'
    catphan_radius_mm = 101
    modules = {
        CTP404CP504: {'offset': 0},
        CTP486: {'offset': -65},
        CTP528CP504: {'offset': 30},
        CTP515: {'offset': -30}
    }

    @staticmethod
    def run_demo(show: bool=True):
        """Run the CBCT demo using high-quality head protocol images."""
        cbct = CatPhan504.from_demo_images()
        cbct.analyze()
        print(cbct.results())
        cbct.plot_analyzed_image(show) class CatPhan600(CatPhanBase):
    """A class for loading and analyzing CT DICOM files of a CatPhan 600.
    Analyzes: Uniformity (CTP486), High-Contrast Spatial Resolution (CTP528),
    Image Scaling & HU Linearity (CTP404), and Low contrast (CTP515).
    """
    _demo_url = 'CatPhan600.zip'
    _model = '600'
    catphan_radius_mm = 101
    modules = {
        CTP404CP600: {'offset': 0},
        CTP486: {'offset': -160},
        CTP515CP600: {'offset': -110},
        CTP528CP600: {'offset': -70},
    }

    @staticmethod
    def run_demo(show: bool=True):
        """Run the CatPhan 600 demo."""
        cbct = CatPhan600.from_demo_images()
        cbct.analyze()
        print(cbct.results())
        cbct.plot_analyzed_image(show) class CatPhan503(CatPhanBase):
    """A class for loading and analyzing CT DICOM files of a CatPhan 503.
    Analyzes: Uniformity (CTP486), High-Contrast Spatial Resolution (CTP528), Image Scaling & HU Linearity (CTP404).
    """
    _demo_url = 'CatPhan503.zip'
    _model = '503'
    catphan_radius_mm = 97
    modules = {
        CTP404CP503: {'offset': 0},
        CTP486: {'offset': -110},
        CTP528CP503: {'offset': -30},
    }

    @staticmethod
    def run_demo(show: bool=True):
        """Run the CBCT demo using high-quality head protocol images."""
        cbct = CatPhan503.from_demo_images()
        cbct.analyze()
        print(cbct.results())
        cbct.plot_analyzed_image(show) class CatPhan604(CatPhanBase):
    """A class for loading and analyzing CT DICOM files of a CatPhan 604. Can be from a CBCT or CT scanner
    Analyzes: Uniformity (CTP486), High-Contrast Spatial Resolution (CTP528),
    Image Scaling & HU Linearity (CTP404), and Low contrast (CTP515).
    """
    _demo_url = 'CatPhan604.zip'
    _model = '604'
    catphan_radius_mm = 101
    modules = {
        CTP404CP604: {'offset': 0},
        CTP486: {'offset': -80},
        CTP528CP604: {'offset': 42},
        CTP515: {'offset': -40}
    }

    @staticmethod
    def run_demo(show: bool=True):
        """Run the CBCT demo using high-quality head protocol images."""
        cbct = CatPhan604.from_demo_images()
        cbct.analyze()
        print(cbct.results())
        cbct.plot_analyzed_image(show) class Point:
    """A geometric point with x, y, and z coordinates/attributes."""
    z: Union[int, float]
    y: Union[int, float]
    x: Union[int, float]
    _attr_list: List[str] = ['x', 'y', 'z', 'idx', 'value']
    _coord_list: List[str] = ['x', 'y', 'z']

    def __init__(self, x: Union[NumberLike, tuple, 'Point'] = 0, y: NumberLike = 0, z: NumberLike = 0, idx: Optional[int]=None,
                 value: Optional[NumberLike]=None, as_int: bool=False):
        """
        Parameters
        ----------
        x : number-like, Point, iterable
            x-coordinate or iterable type containing all coordinates. If iterable, values are assumed to be in order: (x,y,z).
        y : number-like, optional
            y-coordinate
        idx : int, optional
            Index of point. Useful for sequential coordinates; e.g. a point on a circle profile is sometimes easier to describe
            in terms of its index rather than x,y coords.
        value : number-like, optional
            value at point location (e.g. pixel value of an image)
        as_int : boolean
            If True, coordinates are converted to integers.
        """
        if isinstance(x, Point):
            for attr in self._attr_list:
                item = getattr(x, attr, None)
                setattr(self, attr, item)
        elif is_iterable(x):
            for attr, item in zip_longest(self._attr_list, x, fillvalue=0):
                setattr(self, attr, item)
        else:
            self.x = x
            self.y = y
            self.z = z
            self.idx = idx
            self.value = value

        if as_int:
            self.x = int(round(self.x))
            self.y = int(round(self.y))
            self.z = int(round(self.z))

    def distance_to(self, thing: Union['Point', 'Circle']) -> float:
        """Calculate the distance to the given point.

        Parameters
        ----------
        thing : Circle, Point, 2 element iterable
            The other thing to calculate distance to.
        """
        if isinstance(thing, Circle):
            return abs(np.sqrt((self.x - thing.center.x)**2 + (self.y - thing.center.y)**2) - thing.radius)
        p = Point(thing)
        return math.sqrt((self.x - p.x)**2 + (self.y - p.y)**2 + (self.z - p.z)**2)

    def as_array(self, only_coords: bool=True) -> np.array:
        """Return the point as a numpy array."""
        if only_coords:
            return np.array([getattr(self, item) for item in self._coord_list])
        else:
            return np.array([getattr(self, item) for item in self._attr_list if (getattr(self, item) is not None)])

    def __repr__(self):
        return f"Point(x={self.x:3.2f}, y={self.y:3.2f}, z={self.z:3.2f})"

    def __eq__(self, other):
        # if all attrs equal, points considered equal
        return all(getattr(self, attr) == getattr(other, attr) for attr in self._attr_list)

    def __sub__(self, other):
        p = Point()
        for attr in self._attr_list:
            try:
                diff = getattr(self, attr) - getattr(other, attr)
            except TypeError:
                diff = None
            setattr(p, attr, diff)
        return p

    def __mul__(self, other):
        for attr in self._attr_list:
            try:
                self.__dict__[attr] *= other
            except TypeError:
                pass

    def __truediv__(self, other):
        for attr in self._attr_list:
            val = getattr(self, attr)
            try:
                setattr(self, attr, val/other)
            except TypeError:
                pass
        return self class TemporaryZipDirectory(TemporaryDirectory):
    """Creates a temporary directory that unpacks a ZIP archive."""
    def __init__(self, zfile):
        """
        Parameters
        ----------
        zfile : str
            String that points to a ZIP archive.
        """
        super().__init__()
        zfiles = zipfile.ZipFile(zfile)
        zfiles.extractall(path=self.name) class CTP404CP504(CatPhanModule):
    """Class for analysis of the HU linearity, geometry, and slice thickness regions of the CTP404.
    """
    attr_name = 'ctp404'
    common_name = 'HU Linearity'
    roi_dist_mm = 58.7
    roi_radius_mm = 5
    roi_settings = {
        'Air': {'value': AIR, 'angle': -90, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'PMP': {'value': PMP, 'angle': -120, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'LDPE': {'value': LDPE, 'angle': 180, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'Poly': {'value': POLY, 'angle': 120, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'Acrylic': {'value': ACRYLIC, 'angle': 60, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'Delrin': {'value': DELRIN, 'angle': 0, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        'Teflon': {'value': TEFLON, 'angle': -60, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
    }
    background_roi_settings = {
        '1': {'angle': -30, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        '2': {'angle': -150, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        '3': {'angle': -210, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
        '4': {'angle': 30, 'distance': roi_dist_mm, 'radius': roi_radius_mm},
    }
    # thickness
    thickness_roi_height = 40
    thickness_roi_width = 10
    thickness_roi_distance_mm = 38
    thickness_roi_settings = {
        'Left': {'angle': 180, 'width': thickness_roi_width, 'height': thickness_roi_height, 'distance': thickness_roi_distance_mm},
        'Bottom': {'angle': 90, 'width': thickness_roi_height, 'height': thickness_roi_width, 'distance': thickness_roi_distance_mm},
        'Right': {'angle': 0, 'width': thickness_roi_width, 'height': thickness_roi_height, 'distance': thickness_roi_distance_mm},
        'Top': {'angle': -90, 'width': thickness_roi_height, 'height': thickness_roi_width, 'distance': thickness_roi_distance_mm},
    }
    # geometry
    geometry_roi_size_mm = 35
    geometry_roi_settings = {
        'Top-Horizontal': (0, 1),
        'Bottom-Horizontal': (2, 3),
        'Left-Vertical': (0, 2),
        'Right-Vertical': (1, 3),
    }

    def __init__(self, catphan, offset: int, hu_tolerance: float, thickness_tolerance: float, scaling_tolerance: float):
        """
        Parameters
        ----------
        catphan : `~pylinac.cbct.CatPhanBase` instance.
        offset : int
        hu_tolerance : float
        thickness_tolerance : float
        scaling_tolerance : float
        """
        self.mm_per_pixel = catphan.mm_per_pixel
        self.hu_tolerance = hu_tolerance
        self.thickness_tolerance = thickness_tolerance
        self.scaling_tolerance = scaling_tolerance
        self.thickness_rois = {}
        self.lines = {}
        super().__init__(catphan, tolerance=hu_tolerance, offset=offset)

    def _convert_units_in_settings(self):
        super()._convert_units_in_settings()
        for roi, settings in self.thickness_roi_settings.items():
            self.thickness_roi_settings[roi]['width_pixels'] = settings['width'] / self.mm_per_pixel
            self.thickness_roi_settings[roi]['height_pixels'] = settings['height'] / self.mm_per_pixel
            self.thickness_roi_settings[roi]['angle_corrected'] = settings['angle'] + self.catphan_roll
            self.thickness_roi_settings[roi]['distance_pixels'] = settings['distance'] / self.mm_per_pixel

    def preprocess(self, catphan) -> None:
        # for the thickness analysis image, combine thin slices or just use one slice if slices are thick
        if float(catphan.dicom_stack.metadata.SliceThickness) < 3.5:
            self.pad = 1
        else:
            self.pad = 0
        self.thickness_image = Slice(catphan, combine_method='mean', num_slices=self.num_slices+self.pad, slice_num=self.slice_num).image

    def _setup_rois(self) -> None:
        super()._setup_rois()
        self._setup_thickness_rois()
        self._setup_geometry_rois()

    def _setup_thickness_rois(self) -> None:
        for name, setting in self.thickness_roi_settings.items():
            self.thickness_rois[name] = ThicknessROI(self.thickness_image, setting['width_pixels'],
                                                     setting['height_pixels'], setting['angle_corrected'],
                                                     setting['distance_pixels'], self.phan_center)

    def _setup_geometry_rois(self) -> None:
        boxsize = self.geometry_roi_size_mm / self.mm_per_pixel
        xbounds = (int(self.phan_center.x-boxsize), int(self.phan_center.x+boxsize))
        ybounds = (int(self.phan_center.y-boxsize), int(self.phan_center.y+boxsize))
        geo_img = self.image[ybounds[0]:ybounds[1], xbounds[0]:xbounds[1]]
        larr, regionprops, num_roi = get_regions(geo_img, fill_holes=True, clear_borders=False)
        # check that there is at least 1 ROI
        if num_roi < 4:
            raise ValueError("Unable to locate the Geometric nodes")
        elif num_roi > 4:
            regionprops = sorted(regionprops, key=lambda x: x.filled_area, reverse=True)[:4]
        sorted_regions = sorted(regionprops, key=lambda x: (2*x.centroid[0]+x.centroid[1]))
        centers = [Point(r.weighted_centroid[1]+xbounds[0], r.weighted_centroid[0]+ybounds[0]) for r in sorted_regions]
        #  setup the geometric lines
        for name, order in self.geometry_roi_settings.items():
            self.lines[name] = GeometricLine(centers[order[0]], centers[order[1]], self.mm_per_pixel, self.scaling_tolerance)

    @property
    def lcv(self) -> float:
        """The low-contrast visibility"""
        return 2 * abs(self.rois['LDPE'].pixel_value - self.rois['Poly'].pixel_value) / (self.rois['LDPE'].std + self.rois['Poly'].std)

    def plot_linearity(self, axis: Optional[plt.Axes]=None, plot_delta: bool=True) -> tuple:
        """Plot the HU linearity values to an axis.

        Parameters
        ----------
        axis : None, matplotlib.Axes
            The axis to plot the values on. If None, will create a new figure.
        plot_delta : bool
            Whether to plot the actual measured HU values (False), or the difference from nominal (True).
        """
        nominal_x_values = [roi.nominal_val for roi in self.rois.values()]
        if axis is None:
            fig, axis = plt.subplots()
        if plot_delta:
            values = [roi.value_diff for roi in self.rois.values()]
            nominal_measurements = [0]*len(values)
            ylabel = 'HU Delta'
        else:
            values = [roi.pixel_value for roi in self.rois.values()]
            nominal_measurements = nominal_x_values
            ylabel = 'Measured Values'
        points = axis.plot(nominal_x_values, values, 'g+', markersize=15, mew=2)
        axis.plot(nominal_x_values, nominal_measurements)
        axis.plot(nominal_x_values, np.array(nominal_measurements) + self.hu_tolerance, 'r--')
        axis.plot(nominal_x_values, np.array(nominal_measurements) - self.hu_tolerance, 'r--')
        axis.margins(0.05)
        axis.grid(True)
        axis.set_xlabel("Nominal Values")
        axis.set_ylabel(ylabel)
        axis.set_title("HU linearity")
        return points

    @property
    def passed_hu(self) -> bool:
        """Boolean specifying whether all the ROIs passed within tolerance."""
        return all(roi.passed for roi in self.rois.values())

    def plot_rois(self, axis: plt.Axes) -> None:
        """Plot the ROIs onto the image, as well as the background ROIs"""
        # plot HU linearity ROIs
        super().plot_rois(axis)
        # plot thickness ROIs
        for roi in self.thickness_rois.values():
            roi.plot2axes(axis, edgecolor='blue')
        # plot geometry lines
        for line in self.lines.values():
            line.plot2axes(axis, color=line.pass_fail_color)

    @property
    def passed_thickness(self) -> bool:
        """Whether the slice thickness was within tolerance from nominal."""
        return self.slice_thickness-self.thickness_tolerance<self.meas_slice_thickness<self.slice_thickness+self.thickness_tolerance

    @property
    def meas_slice_thickness(self) -> float:
        """The average slice thickness for the 4 wire measurements in mm."""
        return np.mean(sorted(roi.wire_fwhm*self.mm_per_pixel*0.42 for roi in self.thickness_rois.values()))/(1+2*self.pad)

    @property
    def avg_line_length(self) -> float:
        return float(np.mean([line.length_mm for line in self.lines.values()]))

    @property
    def passed_geometry(self) -> bool:
        """Returns whether all the line lengths were within tolerance."""
        return all(line.passed for line in self.lines.values()) class CTP404CP503(CTP404CP504):
    """Alias for namespace consistency"""
    pass class CTP528CP503(CTP528CP504):
    start_angle = 0
    ccw = False
    boundaries = (0, 0.111, 0.176, 0.240, 0.289, 0.339, 0.390, 0.436, 0.481) class CTP528CP504(CatPhanModule):
    """Class for analysis of the Spatial Resolution slice of the CBCT dicom data set.

    A collapsed circle profile is taken of the line-pair region. This profile is search for
    peaks and valleys. The MTF is calculated from those peaks & valleys.

    Attributes
    ----------
    radius2linepairs_mm : float
        The radius in mm to the line pairs.
    """
    attr_name: str = 'ctp528'
    common_name: str = 'Spatial Resolution'
    radius2linepairs_mm = 47
    combine_method: str = 'max'
    num_slices: int = 3
    boundaries: Tuple[float, ...] = (0, 0.107, 0.173, 0.236, 0.286, 0.335, 0.387, 0.434, 0.479)
    start_angle: float = np.pi
    ccw: bool = True
    roi_settings = {
        'region 1': {'start': boundaries[0], 'end': boundaries[1], 'num peaks': 2, 'num valleys': 1,
                     'peak spacing': 0.021, 'gap size (cm)': 0.5, 'lp/mm': 0.1},
        'region 2': {'start': boundaries[1], 'end': boundaries[2], 'num peaks': 3, 'num valleys': 2,
                     'peak spacing': 0.01, 'gap size (cm)': 0.25, 'lp/mm': 0.2},
        'region 3': {'start': boundaries[2], 'end': boundaries[3], 'num peaks': 4, 'num valleys': 3,
                     'peak spacing': 0.006, 'gap size (cm)': 0.167, 'lp/mm': 0.3},
        'region 4': {'start': boundaries[3], 'end': boundaries[4], 'num peaks': 4, 'num valleys': 3,
                     'peak spacing': 0.00557, 'gap size (cm)': 0.125, 'lp/mm': 0.4},
        'region 5': {'start': boundaries[4], 'end': boundaries[5], 'num peaks': 4, 'num valleys': 3,
                     'peak spacing': 0.004777, 'gap size (cm)': 0.1, 'lp/mm': 0.5},
        'region 6': {'start': boundaries[5], 'end': boundaries[6], 'num peaks': 5, 'num valleys': 4,
                     'peak spacing': 0.00398, 'gap size (cm)': 0.083, 'lp/mm': 0.6},
        'region 7': {'start': boundaries[6], 'end': boundaries[7], 'num peaks': 5, 'num valleys': 4,
                     'peak spacing': 0.00358, 'gap size (cm)': 0.071, 'lp/mm': 0.7},
        'region 8': {'start': boundaries[7], 'end': boundaries[8], 'num peaks': 5, 'num valleys': 4,
                     'peak spacing': 0.0027866, 'gap size (cm)': 0.063, 'lp/mm': 0.8},
    }

    def _setup_rois(self):
        pass

    def _convert_units_in_settings(self):
        pass

    @cached_property
    def mtf(self) -> MTF:
        """The Relative MTF of the line pairs, normalized to the first region.

        Returns
        -------
        dict
        """
        maxs = list()
        mins = list()
        for key, value in self.roi_settings.items():
            max_indices, max_values = self.circle_profile.find_peaks(min_distance=value['peak spacing'], max_number=value['num peaks'],
                                                        search_region=(value['start'], value['end']))
            # check that the right number of peaks were found before continuing, otherwise stop searching for regions
            if len(max_values) != value['num peaks']:
                break
            maxs.append(max_values.mean())
            _, min_values = self.circle_profile.find_valleys(min_distance=value['peak spacing'], max_number=value['num valleys'],
                                                             search_region=(min(max_indices), max(max_indices)))
            mins.append(min_values.mean())
        if not maxs:
            raise ValueError("Did not find any spatial resolution pairs to analyze. File an issue on github (https://github.com/jrkerns/pylinac/issues) if this is a valid dataset.")

        spacings = [roi['lp/mm'] for roi in self.roi_settings.values()]
        mtf = MTF(lp_spacings=spacings, lp_maximums=maxs, lp_minimums=mins)
        return mtf

    @property
    def radius2linepairs(self) -> float:
        """Radius from the phantom center to the line-pair region, corrected for pixel spacing."""
        return self.radius2linepairs_mm / self.mm_per_pixel

    def plot_rois(self, axis: plt.Axes) -> None:
        """Plot the circles where the profile was taken within."""
        self.circle_profile.plot2axes(axis, edgecolor='blue', plot_peaks=False)

    @cached_property
    def circle_profile(self) -> CollapsedCircleProfile:
        """Calculate the median profile of the Line Pair region.

        Returns
        -------
        :class:`pylinac.core.profile.CollapsedCircleProfile` : A 1D profile of the Line Pair region.
        """
        circle_profile = CollapsedCircleProfile(self.phan_center, self.radius2linepairs, image_array=self.image,
                                                start_angle=self.start_angle + np.deg2rad(self.catphan_roll),
                                                width_ratio=0.04, sampling_ratio=2, ccw=self.ccw)
        circle_profile.filter(0.001, kind='gaussian')
        circle_profile.ground()
        return circle_profile

    def plot_mtf(self, axis: Optional[plt.Axes] = None) -> Tuple:
        """Plot the Relative MTF.

        Parameters
        ----------
        axis : None, matplotlib.Axes
            The axis to plot the MTF on. If None, will create a new figure.
        """
        if axis is None:
            fig, axis = plt.subplots()
        points = axis.plot(list(self.mtf.norm_mtfs.keys()), list(self.mtf.norm_mtfs.values()), marker='o')
        axis.margins(0.05)
        axis.grid(True)
        axis.set_xlabel('Line pairs / mm')
        axis.set_ylabel("Relative MTF")
        axis.set_title('RMTF')
        return points class CatphanResult(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."""
    catphan_model: str  #:
    catphan_roll_deg: float  #:
    origin_slice: int  #:
    num_images: int  #:
    ctp404: CTP404Result  #:
    ctp486: Optional[CTP486Result] = None  #:
    ctp528: Optional[CTP528Result] = None  #:
    ctp515: Optional[CTP515Result] = None  #: 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 class InitTesterMixin(MixinTesterBase):
    init_file: List[str]
    init_kwargs: dict = {}
    is_folder = False

    @property
    def full_init_file(self) -> str:
        if self.is_folder:
            return get_folder_from_cloud_test_repo(self.init_file)
        else:
            return get_file_from_cloud_test_repo(self.init_file)

    def test_init(self):
        inst = self.klass(self.full_init_file, **self.init_kwargs)
        self.assertIsInstance(inst, self.klass) class FromDemoImageTesterMixin(MixinTesterBase):
    demo_load_method: str = "from_demo_image"

    def test_from_demo(self):
        inst = getattr(self.klass, self.demo_load_method)()
        self.assertIsInstance(inst, self.klass) class FromURLTesterMixin(MixinTesterBase):
    url: str
    url_kwargs: dict = {}

    @property
    def full_url(self):
        return r"https://storage.googleapis.com/pylinac_demo_files/" + self.url

    def test_from_url(self):
        self.klass.from_url(self.full_url, **self.url_kwargs) class FromZipTesterMixin(MixinTesterBase):
    zip: List[str]
    zip_kwargs: dict = {}

    def test_from_zip(self):
        file = get_file_from_cloud_test_repo(self.zip)
        inst = self.klass.from_zip(file, **self.zip_kwargs)
        self.assertIsInstance(inst, self.klass)
import io
import os
import os.path as osp
import matplotlib.pyplot as plt
import numpy as np
from unittest import TestCase, skip
from pylinac import CatPhan503, CatPhan504, CatPhan600, CatPhan604
from pylinac.core.geometry import Point
from pylinac.core.io import TemporaryZipDirectory
from pylinac.ct import CTP404CP504, CTP404CP503, CTP528CP503, CTP528CP504, CatphanResult
from tests_basic.utils import save_file, CloudFileMixin, get_file_from_cloud_test_repo, InitTesterMixin, \
    FromDemoImageTesterMixin, FromURLTesterMixin, FromZipTesterMixin
    def test_load_from_file_object(self):
        path = get_file_from_cloud_test_repo([TEST_DIR, 'CBCT_4.zip'])
        ref_cbct = CatPhan504.from_zip(path)
        with open(path, 'rb') as f:
            cbct = CatPhan504.from_zip(f)
            cbct.analyze()
        self.assertIsInstance(cbct, CatPhan504)
        self.assertEqual(cbct.origin_slice, ref_cbct.origin_slice)

    def test_demo(self):
        """Run the demo to make sure it works."""
        self.cbct.run_demo()

    def test_helpers(self):
        """Test the various helper methods."""
        self.cbct.analyze()
        self.cbct._results()

    def test_phan_center(self):
        """Test locations of the phantom center."""
        known_phan_center = Point(257, 255)
        self.cbct.analyze()
        self.assertAlmostEqual(self.cbct.ctp404.phan_center.x, known_phan_center.x, delta=0.7)
        self.assertAlmostEqual(self.cbct.ctp404.phan_center.y, known_phan_center.y, delta=0.7)

    def test_results_data(self):
        self.cbct.analyze()
        data = self.cbct.results_data()
        self.assertIsInstance(data, CatphanResult)
        self.assertEqual(data.num_images, self.cbct.num_images)

        # check the additional modules got added
        self.assertAlmostEqual(data.ctp528.start_angle_radians, np.pi, delta=0.02)

    def test_contrast_str(self):
        # shouldn't raise
        self.cbct.analyze(contrast_method='Michelson')
        self.cbct.results_data()


class TestCustomPhantom(TestCase):

    def test_removing_module(self):
        class CatPhan504Modified(CatPhan504):
            modules = {
                CTP404CP504: {'offset': 0},
            }
        ct = CatPhan504Modified.from_demo_images()  # shouldn't raise
        ct.analyze()

    def test_omitting_404_module(self):
        class CatPhan504Modified(CatPhan504):
            modules = {
                CTP528CP504: {'offset': -30},
            }
        ct = CatPhan504Modified.from_demo_images()  # shouldn't raise
        with self.assertRaises(ValueError):
            ct.analyze()

    def test_modifying_module(self):
        class CTP528Custom(CTP528CP503):
            start_angle = 0.1

        class CatPhan503Modified(CatPhan503):
            modules = {
                CTP404CP503: {'offset': 0},
                CTP528Custom: {'offset': -30},
            }
        ct = CatPhan503Modified.from_demo_images()  # shouldn't raise
        ct.analyze()
        ct.plot_analyzed_image()


class TestDemos(TestCase):

    def test_504(self):
        CatPhan504.run_demo()

    def test_503(self):
        CatPhan503.run_demo()

    def test_600(self):
        CatPhan600.run_demo()

    def test_604(self):
        CatPhan604.run_demo()


class TestPlottingSaving(TestCase):

    @classmethod
    def setUpClass(cls):
        cls.cbct = CatPhan504.from_demo_images()
        cls.cbct.analyze()

    @classmethod
    def tearDownClass(cls):
        plt.close('all')

    def test_save_image(self):
        """Test that saving an image does something."""
        for method in ['save_analyzed_image', 'save_analyzed_subimage']:
            methodcall = getattr(self.cbct, method)
            save_file(methodcall)

    def test_plot_images(self):
        """Test the various plotting functions."""
        self.cbct.plot_analyzed_image()
        for item in ['hu', 'un', 'mtf', 'sp', 'prof', 'lin', 'lc']:
            self.cbct.plot_analyzed_subimage(item)

        self.cbct.plot_analyzed_subimage('lin', delta=False)

        with self.assertRaises(ValueError):
            self.cbct.plot_analyzed_subimage('sr')


class CatPhanMixin(CloudFileMixin):
    """A mixin to use for testing Varian CBCT scans; does not inherit from TestCase as it would be run
        otherwise."""

    catphan = CatPhan504