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()
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
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()
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.from_image(img_path) >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3) >>> print(mypf.return_results()) >>> mypf.plot_analyzed_image() """ def __init__(self): self.pickets = [] self._action_lvl = None 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) @classmethod def from_image(cls, filepath, filter=None): """Construct a PicketFence instance and load an image. .. versionadded:: 0.6 """ obj = cls() obj.load_image(filepath, 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.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._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 _check_inversion(self): """Check the image for inversion (pickets are valleys, not peaks) by sampling the 4 image corners. If the average value of the four corners is above the average pixel value, then it is very likely inverted. """ outer_edge = 10 inner_edge = 30 TL_corner = self.image.array[outer_edge:inner_edge, outer_edge:inner_edge] BL_corner = self.image.array[-inner_edge:-outer_edge, -inner_edge:-outer_edge] TR_corner = self.image.array[outer_edge:inner_edge, outer_edge:inner_edge] BR_corner = self.image.array[-inner_edge:-outer_edge, -inner_edge:-outer_edge] corner_avg = np.mean((TL_corner, BL_corner, TR_corner, BR_corner)) if corner_avg > np.mean(self.image.array.flatten()): self.image.invert() 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 # @property # def _analysis_array(self): # return getattr(self, '_aa', self.image.array.copy()) # # @_analysis_array.setter # def _analysis_array(self, array): # if array.shape != self.image.shape: # raise ValueError("Array size is not the same as the original image") # self._aa = array 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']