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