class PicketFence: """A class used for analyzing EPID images where radiation strips have been formed by the MLCs. The strips are assumed to be parallel to one another and normal to the image edge; i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting for any angle. Attributes ---------- pickets: list Holds :class:`~pylinac.picketfence.Picket` objects. image: :class:`~pylinac.core.image.Image` object. Examples -------- Run the demo:: >>> PicketFence().run_demo() Typical session: >>> img_path = r"C:/QA/June/PF.dcm" # the EPID image >>> mypf = PicketFence(img_path) >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3) >>> print(mypf.return_results()) >>> mypf.plot_analyzed_image() """ def __init__(self, filename=None, filter=None): self.pickets = [] self._action_lvl = None if filename is not None: self.load_image(filename, filter) def _clear_attrs(self): """Clear attributes; necessary when new image loaded or analysis done on same image.""" self.pickets = [] self._action_lvl = None @property def passed(self): """Boolean specifying if all MLC positions were within tolerance.""" for picket in self.pickets: for meas in picket.mlc_meas: if not meas.passed: return False return True @property def percent_passing(self): """Return the percentage of MLC positions under tolerance.""" num = 0 num_pass = 0 for picket in self.pickets: num += len(picket._error_array) num_pass += sum(picket._error_array < picket._tolerance) pct_pass = 100 * num_pass / num return pct_pass @property def max_error(self): """Return the maximum error found.""" max_error = 0 for idx, picket in enumerate(self.pickets): if picket.max_error > max_error: max_error = picket.max_error return max_error @property def max_error_picket(self): """Return the picket number where the maximum error occured.""" max_error = 0 where_at = 0 for idx, picket in enumerate(self.pickets): if picket.max_error > max_error: max_error = picket.max_error where_at = idx return where_at @property def max_error_leaf(self): """Return the leaf that had the maximum error.""" picket = self.pickets[self.max_error_picket] return np.argmax(picket._error_array) @property def abs_median_error(self): """Return the median error found.""" median_error = [] for picket in self.pickets: median_error.append(picket.abs_median_error) return max(median_error) @property def _action_lvl_set(self): if self._action_lvl is not None: return True else: return False @property def num_pickets(self): """Return the number of pickets determined.""" return len(self.pickets) @classmethod def from_demo_image(cls): """Construct a PicketFence instance using the demo image. .. versionadded:: 0.6 """ obj = cls() obj.load_demo_image() return obj def load_demo_image(self): """Load the demo image that is included with pylinac.""" im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm') self.load_image(im_open_path) def load_image(self, file_path, filter=None): """Load the image Parameters ---------- file_path : str Path to the image file. filter : int, None If None (default), no filtering will be done to the image. If an int, will perform median filtering over image of size *filter*. """ self.image = Image(file_path) if isinstance(filter, int): self.image.array = spfilt.median_filter(self.image.array, size=filter) self._clear_attrs() @classmethod def from_image_UI(cls): """Construct a PicketFence instance and load an image using a dialog box. .. versionadded:: 0.6 """ obj = cls() obj.load_image_UI() return obj def load_image_UI(self): """Load the image using a UI dialog box.""" path = get_filepath_UI() self.load_image(path) def run_demo(self, tolerance=0.5): """Run the Picket Fence demo using the demo image. See analyze() for parameter info.""" self.load_demo_image() self.analyze(tolerance) print(self.return_results()) self.plot_analyzed_image() def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False): """Analyze the picket fence image. Parameters ---------- tolerance : int, float The tolerance of difference in mm between an MLC pair position and the picket fit line. action_tolerance : int, float, None If None (default), no action tolerance is set or compared to. If an int or float, the MLC pair measurement is also compared to this tolerance. Must be lower than tolerance. This value is usually meant to indicate an "action" is necessary on the part of the physicist to resolve the issue. hdmlc : bool If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed. If True, an HD (2.5/5mm leaves) Millennium is assumed. """ if action_tolerance is not None and tolerance < action_tolerance: raise ValueError("Tolerance cannot be lower than the action tolerance") """Pre-analysis""" self._clear_attrs() self._action_lvl = action_tolerance self.image.check_inversion() self._threshold() self._find_orientation() """Analysis""" self._construct_pickets(tolerance, action_tolerance) leaf_centers = self._find_leaf_centers(hdmlc) self._calc_mlc_positions(leaf_centers) self._calc_mlc_error() def _construct_pickets(self, tolerance, action_tolerance): """Construct the Picket instances.""" if self.orientation == orientations['UD']: leaf_prof = np.median(self._analysis_array, 0) else: leaf_prof = np.median(self._analysis_array, 1) leaf_prof = Profile(leaf_prof) _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=0.01, min_peak_height=0.5) for peak in range(len(peak_idxs)): self.pickets.append(Picket(self.image, tolerance, self.orientation, action_tolerance)) def _find_leaf_centers(self, hdmlc): """Return the leaf centers perpendicular to the leaf motion.""" # generate some settings sm_lf_wdth = 5 * self.image.dpmm bg_lf_wdth = sm_lf_wdth * 2 if hdmlc: sm_lf_wdth /= 2 bg_lf_wdth /= 2 self._sm_lf_meas_wdth = slmw = int(round(sm_lf_wdth*3/4)) self._bg_lf_meas_wdth = blmw = int(round(bg_lf_wdth*3/4)) bl_ex = int(bg_lf_wdth/4) sm_ex = int(sm_lf_wdth/4) # generate leaf profile if self.orientation == orientations['UD']: leaf_prof = np.mean(self._analysis_array, 1) center = self.image.center.y else: leaf_prof = np.mean(self._analysis_array, 0) center = self.image.center.x leaf_prof = Profile(leaf_prof) # ground profile to reasonable level _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=self._sm_lf_meas_wdth, exclude_lt_edge=sm_ex, exclude_rt_edge=sm_ex) min_val = leaf_prof.y_values[peak_idxs[0]:peak_idxs[-1]].min() leaf_prof.y_values[leaf_prof.y_values < min_val] = min_val # remove unevenness in signal leaf_prof.y_values = signal.detrend(leaf_prof.y_values, bp=[int(len(leaf_prof.y_values)/3), int(len(leaf_prof.y_values)*2/3)]) _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=self._sm_lf_meas_wdth, exclude_lt_edge=sm_ex, exclude_rt_edge=sm_ex) leaf_range = (peak_idxs[-1] - peak_idxs[0]) / self.image.dpmm # mm sm_lf_range = 220 # mm # find leaf peaks if leaf_range > sm_lf_range: lt_biglittle_lf_bndry = int(round(center - 100 * self.image.dpmm)) rt_biglittle_lf_bndry = int(round(center + 100 * self.image.dpmm)) pp = leaf_prof.subdivide([lt_biglittle_lf_bndry, rt_biglittle_lf_bndry], slmw) if len(pp) != 3: raise ValueError("3 Profiles weren't found but should have been") # Left Big MLC region _, peak_idxs = pp[0].find_peaks(min_peak_distance=blmw, exclude_lt_edge=bl_ex) peak_diff = np.diff(peak_idxs).mean() lt_v_idx = np.array(peak_idxs[:-1]) + peak_diff/2 # Middle, small MLC region _, peak_idxs = pp[1].find_peaks(min_peak_distance=slmw) peak_diff = np.diff(peak_idxs).mean() mid_v_idx = np.array(peak_idxs[:-1]) + peak_diff / 2 # Right Big MLC region _, peak_idxs = pp[2].find_peaks(min_peak_distance=blmw, exclude_rt_edge=bl_ex) peak_diff = np.diff(peak_idxs).mean() rt_v_idx = np.array(peak_idxs[:-1]) + peak_diff / 2 leaf_center_idxs = np.concatenate((lt_v_idx, mid_v_idx, rt_v_idx)) else: _, peak_idxs = leaf_prof.find_peaks(min_peak_distance=slmw, exclude_lt_edge=sm_ex, exclude_rt_edge=sm_ex) _, peak_idxs = leaf_prof.find_FWXM_peaks(min_peak_distance=slmw, interpolate=True) peak_diff = np.diff(peak_idxs).mean() leaf_center_idxs = np.array(peak_idxs[:-1]) + peak_diff / 2 return leaf_center_idxs def _calc_mlc_positions(self, leaf_centers): """Calculate the positions of all the MLC pairs.""" diff = np.diff(leaf_centers) sample_width = np.round(np.median(diff*2/5)/2).astype(int) for mlc_num, mlc_peak_loc in enumerate(np.round(leaf_centers).astype(int)): mlc_rows = np.arange(mlc_peak_loc-sample_width, mlc_peak_loc+sample_width+1) if self.orientation == orientations['UD']: pix_vals = np.median(self._analysis_array[mlc_rows, :], axis=0) else: pix_vals = np.median(self._analysis_array[:, mlc_rows], axis=1) prof = Profile(pix_vals) prof.find_FWXM_peaks(fwxm=80, min_peak_distance=0.01, min_peak_height=0.5, interpolate=True) for idx, peak in enumerate(prof.peaks): if self.orientation == orientations['UD']: meas = MLC_Meas((peak.idx, mlc_rows[0]), (peak.idx, mlc_rows[-1])) else: meas = MLC_Meas((mlc_rows[0], peak.idx), (mlc_rows[-1], peak.idx)) self.pickets[idx].mlc_meas.append(meas) def _calc_mlc_error(self): """Calculate the error of the MLC positions relative to the picket fit.""" for picket in self.pickets: picket.fit_poly() picket.calc_mlc_errors() def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, show=True): """Plot the analyzed image. Parameters ---------- guard_rails : bool Do/don't plot the picket "guard rails". mlc_peaks : bool Do/don't plot the MLC positions. overlay : bool Do/don't plot the alpha overlay of the leaf status. """ # plot the image plt.clf() ax = plt.imshow(self.image.array, cmap=plt.cm.Greys) # plot guard rails and mlc peaks as desired for p_num, picket in enumerate(self.pickets): if guard_rails: picket.add_guards_to_axes(ax.axes) if mlc_peaks: for idx, mlc_meas in enumerate(picket.mlc_meas): if not mlc_meas.passed: color = 'r' elif self._action_lvl_set and not mlc_meas.passed_action: color = 'm' else: color = 'b' mlc_meas.add_to_axes(ax.axes, color=color, width=1.5) # plot the overlay if desired. if overlay: for mlc_num, mlc in enumerate(self.pickets[0].mlc_meas): below_tol = True if self._action_lvl_set: below_action = True for picket in self.pickets: if not picket.mlc_passed(mlc_num): below_tol = False if self._action_lvl_set and not picket.mlc_passed_action(mlc_num): below_action = False if below_tol: if self._action_lvl_set and not below_action: color = 'm' else: color = 'g' else: color = 'r' if self.orientation == orientations['UD']: r = Rectangle(max(self.image.shape)*2, self._sm_lf_meas_wdth, (mlc.center.x, mlc.center.y)) else: r = Rectangle(self._sm_lf_meas_wdth, max(self.image.shape) * 2, (mlc.center.x, mlc.center.y)) r.add_to_axes(ax.axes, edgecolor='none', fill=True, alpha=0.1, facecolor=color) plt.xlim([0, self.image.shape[1]]) plt.ylim([0, self.image.shape[0]]) plt.axis('off') if show: plt.show() def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, **kwargs): """Save the analyzed figure to a file.""" self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, show=False) plt.savefig(filename, **kwargs) def return_results(self): """Return results of analysis. Use with print().""" pass_pct = self.percent_passing string = "Picket Fence Results: \n{:2.1f}% " \ "Passed\nMedian Error: {:2.3f}mm \n" \ "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error, self.max_error_picket, self.max_error_leaf) return string def _threshold(self): """Threshold the image by subtracting the minimum value. Allows for more accurate image orientation determination. """ col_prof = np.median(self.image.array, 0) col_prof = Profile(col_prof) row_prof = np.median(self.image.array, 1) row_prof = Profile(row_prof) _, r_peak_idx = row_prof.find_peaks(min_peak_distance=0.01, exclude_lt_edge=0.05, exclude_rt_edge=0.05) _, c_peak_idx = col_prof.find_peaks(min_peak_distance=0.01, exclude_lt_edge=0.05, exclude_rt_edge=0.05) min_val = self.image.array[r_peak_idx[0]:r_peak_idx[-1], c_peak_idx[0]:c_peak_idx[-1]].min() self._analysis_array = self.image.array.copy() self._analysis_array[self._analysis_array < min_val] = min_val self._analysis_array -= min_val def _find_orientation(self): """Determine the orientation of the radiation strips by examining percentiles of the sum of each axes of the image. A high standard deviation is a surrogate for the axis the pickets are along. """ row_sum = np.sum(self._analysis_array, 0) col_sum = np.sum(self._analysis_array, 1) row80, row90 = np.percentile(row_sum, [80, 90]) col80, col90 = np.percentile(col_sum, [80, 90]) row_range = row90 - row80 col_range = col90 - col80 # The true picket side will have a greater difference in # percentiles than will the non-picket size. if row_range < col_range: self.orientation = orientations['LR'] else: self.orientation = orientations['UD']
class PicketFence: """A class used for analyzing EPID images where radiation strips have been formed by the MLCs. The strips are assumed to be parallel to one another and normal to the image edge; i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting for any angle. Attributes ---------- pickets: :class:`~pylinac.picketfence.PicketHandler` image: :class:`~pylinac.core.image.Image` Examples -------- Run the demo:: >>> PicketFence().run_demo() Typical session: >>> img_path = r"C:/QA/June/PF.dcm" # the EPID image >>> mypf = PicketFence(img_path) >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3) >>> print(mypf.return_results()) >>> mypf.plot_analyzed_image() """ def __init__(self, filename=None, filter=None): """ Parameters ---------- filename : str, None Name of the file as a string. If None, image must be loaded later. filter : int, None The filter size to apply to the image upon load. """ if filename is not None: self.load_image(filename, filter) @classmethod def from_url(cls, url, filter=None): """Instantiate from a URL. .. versionadded:: 0.7.1 """ obj = cls() obj.load_url(url, filter=filter) return obj def load_url(self, url, filter=None): """Load from a URL. .. versionadded:: 0.7.1 """ try: import requests except ImportError: raise ImportError("Requests is not installed; cannot get the log from a URL") response = requests.get(url) if response.status_code != 200: raise ConnectionError("Could not connect to the URL") stream = BytesIO(response.content) self.load_image(stream, filter=filter) @property def passed(self): """Boolean specifying if all MLC positions were within tolerance.""" return self.pickets.passed @property def percent_passing(self): """Return the percentage of MLC positions under tolerance.""" num = 0 num_pass = 0 for picket in self.pickets: num += len(picket.error_array) num_pass += sum(picket.error_array < self.settings.tolerance) pct_pass = 100 * num_pass / num return pct_pass @property def max_error(self): """Return the maximum error found.""" return max(picket.max_error for picket in self.pickets) @property def max_error_picket(self): """Return the picket number where the maximum error occured.""" return np.argmax([picket.max_error for picket in self.pickets]) @property def max_error_leaf(self): """Return the leaf that had the maximum error.""" picket = self.pickets[self.max_error_picket] return np.argmax(picket.error_array) @property @lru_cache() def abs_median_error(self): """Return the median error found.""" return np.median(np.hstack([picket.error_array for picket in self.pickets])) @property def num_pickets(self): """Return the number of pickets determined.""" return len(self.pickets) @classmethod def from_demo_image(cls, filter=None): """Construct a PicketFence instance using the demo image. .. versionadded:: 0.6 """ obj = cls() obj.load_demo_image(filter=filter) return obj def load_demo_image(self, filter=None): """Load the demo image that is included with pylinac.""" im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm') self.load_image(im_open_path, filter=filter) def load_image(self, file_path, filter=None): """Load the image Parameters ---------- file_path : str Path to the image file. filter : int, None If None (default), no filtering will be done to the image. If an int, will perform median filtering over image of size *filter*. """ self.image = Image(file_path) if isinstance(filter, int): self.image.median_filter(size=filter) self._check_for_noise() self.image.check_inversion() @classmethod def from_image_UI(cls, filter=None): """Construct a PicketFence instance and load an image using a dialog box. .. versionadded:: 0.6 """ obj = cls() obj.load_image_UI(filter=filter) return obj def load_image_UI(self, filter=None): """Load the image using a UI dialog box.""" path = get_filepath_UI() self.load_image(path, filter=filter) def _check_for_noise(self): """Check if the image has extreme noise (dead pixel, etc) by comparing min/max to 1/99 percentiles and smoothing if need be.""" while self._has_noise(): self.image.median_filter() def _has_noise(self): """Helper method to determine if there is spurious signal in the image.""" min = self.image.array.min() max = self.image.array.max() near_min, near_max = np.percentile(self.image.array, [0.5, 99.5]) max_is_extreme = max > near_max * 2 min_is_extreme = (min < near_min) and (abs(near_min - min) > 0.2 * near_max) return max_is_extreme or min_is_extreme def _adjust_for_sag(self, sag): """Roll the image to adjust for EPID sag.""" sag_pixels = int(round(sag * self.settings.dpmm)) direction = 'y' if self.orientation == orientations['UD'] else 'x' self.image.roll(direction, sag_pixels) def run_demo(self, tolerance=0.5): """Run the Picket Fence demo using the demo image. See analyze() for parameter info.""" self.load_demo_image() self.analyze(tolerance) print(self.return_results()) self.plot_analyzed_image() def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False, num_pickets=None, sag_adjustment=0): """Analyze the picket fence image. Parameters ---------- tolerance : int, float The tolerance of difference in mm between an MLC pair position and the picket fit line. action_tolerance : int, float, None If None (default), no action tolerance is set or compared to. If an int or float, the MLC pair measurement is also compared to this tolerance. Must be lower than tolerance. This value is usually meant to indicate that a physicist should take an "action" to reduce the error, but should not stop treatment. hdmlc : bool If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed. If True, an HD (2.5/5mm leaves) Millennium is assumed. num_pickets : int, None .. versionadded:: 0.8 The number of pickets in the image. A helper parameter to limit the total number of pickets, only needed if analysis is catching things that aren't pickets. sag_adjustment : float, int .. versionadded:: 0.8 The amount of shift in mm to apply to the image to correct for EPID sag. For Up-Down picket images, positive moves the image down, negative up. For Left-Right picket images, positive moves the image left, negative right. """ if action_tolerance is not None and tolerance < action_tolerance: raise ValueError("Tolerance cannot be lower than the action tolerance") """Pre-analysis""" self.settings = Settings(self.orientation, tolerance, action_tolerance, hdmlc, self.image.dpmm) self._adjust_for_sag(sag_adjustment) """Analysis""" self.pickets = PicketHandler(self.image, self.settings, num_pickets) def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, show=True): """Plot the analyzed image. Parameters ---------- guard_rails : bool Do/don't plot the picket "guard rails". mlc_peaks : bool Do/don't plot the MLC positions. overlay : bool Do/don't plot the alpha overlay of the leaf status. """ # plot the image plt.clf() ax = plt.imshow(self.image.array, cmap=plt.cm.Greys) # plot guard rails and mlc peaks as desired for p_num, picket in enumerate(self.pickets): if guard_rails: picket.add_guards_to_axes(ax.axes) if mlc_peaks: for idx, mlc_meas in enumerate(picket.mlc_meas): mlc_meas.add_to_axes(ax.axes, width=1.5) # plot the overlay if desired. if overlay: o = Overlay(self.image, self.settings, self.pickets) o.add_to_axes(ax) plt.xlim([0, self.image.shape[1]]) plt.ylim([0, self.image.shape[0]]) plt.axis('off') if show: plt.show() def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, **kwargs): """Save the analyzed figure to a file.""" self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, show=False) plt.savefig(filename, **kwargs) def return_results(self): """Return results of analysis. Use with print().""" pass_pct = self.percent_passing string = "Picket Fence Results: \n{:2.1f}% " \ "Passed\nMedian Error: {:2.3f}mm \n" \ "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error, self.max_error_picket, self.max_error_leaf) return string @property def orientation(self): """The orientation of the image, either Up-Down or Left-Right.""" # replace any dead pixels with median value temp_image = self.image.array.copy() temp_image[temp_image < np.median(temp_image)] = np.median(temp_image) # find "range" of 80 to 90th percentiles row_sum = np.sum(temp_image, 0) col_sum = np.sum(temp_image, 1) row80, row90 = np.percentile(row_sum, [80, 90]) col80, col90 = np.percentile(col_sum, [80, 90]) row_range = row90 - row80 col_range = col90 - col80 # The true picket side will have a greater difference in # percentiles than will the non-picket size. if row_range < col_range: orientation = orientations['LR'] else: orientation = orientations['UD'] return orientation
class PicketFence: """A class used for analyzing EPID images where radiation strips have been formed by the MLCs. The strips are assumed to be parallel to one another and normal to the image edge; i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting for any angle. Attributes ---------- pickets: :class:`~pylinac.picketfence.PicketHandler` image: :class:`~pylinac.core.image.Image` Examples -------- Run the demo:: >>> PicketFence().run_demo() Typical session: >>> img_path = r"C:/QA/June/PF.dcm" # the EPID image >>> mypf = PicketFence(img_path) >>> mypf.analyze(tolerance=0.5, action_tolerance=0.3) >>> print(mypf.return_results()) >>> mypf.plot_analyzed_image() """ def __init__(self, filename=None, filter=None): """ Parameters ---------- filename : str, None Name of the file as a string. If None, image must be loaded later. filter : int, None The median filter size to apply to the image upon load. """ if filename is not None: self.load_image(filename, filter) @classmethod def from_url(cls, url, filter=None): """Instantiate from a URL. .. versionadded:: 0.7.1 """ obj = cls() obj.load_url(url, filter=filter) return obj def load_url(self, url, filter=None): """Load from a URL. .. versionadded:: 0.7.1 """ response = get_url(url) stream = BytesIO(response.content) self.load_image(stream, filter=filter) @property def passed(self): """Boolean specifying if all MLC positions were within tolerance.""" return self.pickets.passed @property def percent_passing(self): """Return the percentage of MLC positions under tolerance.""" num = 0 num_pass = 0 for picket in self.pickets: num += len(picket.error_array) num_pass += sum(picket.error_array < self.settings.tolerance) pct_pass = 100 * num_pass / num return pct_pass @property def max_error(self): """Return the maximum error found.""" return max(picket.max_error for picket in self.pickets) @property def max_error_picket(self): """Return the picket number where the maximum error occured.""" return np.argmax([picket.max_error for picket in self.pickets]) @property def max_error_leaf(self): """Return the leaf that had the maximum error.""" picket = self.pickets[self.max_error_picket] return np.argmax(picket.error_array) @property @lru_cache() def abs_median_error(self): """Return the median error found.""" return np.median(np.hstack([picket.error_array for picket in self.pickets])) @property def num_pickets(self): """Return the number of pickets determined.""" return len(self.pickets) @classmethod def from_demo_image(cls, filter=None): """Construct a PicketFence instance using the demo image. .. versionadded:: 0.6 """ obj = cls() obj.load_demo_image(filter=filter) return obj def load_demo_image(self, filter=None): """Load the demo image that is included with pylinac.""" im_open_path = osp.join(osp.dirname(__file__), 'demo_files', 'picket_fence', 'EPID-PF-LR.dcm') self.load_image(im_open_path, filter=filter) def load_image(self, file_path, filter=None): """Load the image Parameters ---------- file_path : str Path to the image file. filter : int, None If None (default), no filtering will be done to the image. If an int, will perform median filtering over image of size *filter*. """ self.image = Image(file_path) if isinstance(filter, int): self.image.median_filter(size=filter) self._check_for_noise() self.image.check_inversion() @classmethod def from_image_UI(cls, filter=None): """Construct a PicketFence instance and load an image using a dialog box. .. versionadded:: 0.6 """ obj = cls() obj.load_image_UI(filter=filter) return obj def load_image_UI(self, filter=None): """Load the image using a UI dialog box.""" path = get_filepath_UI() self.load_image(path, filter=filter) @classmethod def from_multiple_images(cls, path_list): """Load and superimpose multiple images and instantiate a Starshot object. .. versionadded:: 0.9 Parameters ---------- path_list : iterable An iterable of path locations to the files to be loaded/combined. """ obj = cls() obj.load_multiple_images(path_list) return obj def load_multiple_images(self, path_list): """Load and superimpose multiple images. .. versionadded:: 0.9 Parameters ---------- path_list : iterable An iterable of path locations to the files to be loaded/combined. """ self.image = Image.from_multiples(path_list, method='mean') self._check_for_noise() self.image.check_inversion() def _check_for_noise(self): """Check if the image has extreme noise (dead pixel, etc) by comparing min/max to 1/99 percentiles and smoothing if need be.""" while self._has_noise(): self.image.median_filter() def _has_noise(self): """Helper method to determine if there is spurious signal in the image.""" min = self.image.array.min() max = self.image.array.max() near_min, near_max = np.percentile(self.image.array, [0.5, 99.5]) max_is_extreme = max > near_max * 2 min_is_extreme = (min < near_min) and (abs(near_min - min) > 0.2 * near_max) return max_is_extreme or min_is_extreme def _adjust_for_sag(self, sag): """Roll the image to adjust for EPID sag.""" sag_pixels = int(round(sag * self.settings.dpmm)) direction = 'y' if self.orientation == orientations['UD'] else 'x' self.image.roll(direction, sag_pixels) def run_demo(self, tolerance=0.5, action_tolerance=0.25, interactive=False): """Run the Picket Fence demo using the demo image. See analyze() for parameter info.""" self.load_demo_image() self.analyze(tolerance, action_tolerance=action_tolerance) print(self.return_results()) self.plot_analyzed_image(interactive=interactive, leaf_error_subplot=True) def analyze(self, tolerance=0.5, action_tolerance=None, hdmlc=False, num_pickets=None, sag_adjustment=0): """Analyze the picket fence image. Parameters ---------- tolerance : int, float The tolerance of difference in mm between an MLC pair position and the picket fit line. action_tolerance : int, float, None If None (default), no action tolerance is set or compared to. If an int or float, the MLC pair measurement is also compared to this tolerance. Must be lower than tolerance. This value is usually meant to indicate that a physicist should take an "action" to reduce the error, but should not stop treatment. hdmlc : bool If False (default), a standard (5/10mm leaves) Millennium MLC model is assumed. If True, an HD (2.5/5mm leaves) Millennium is assumed. num_pickets : int, None .. versionadded:: 0.8 The number of pickets in the image. A helper parameter to limit the total number of pickets, only needed if analysis is catching more pickets than there really are. sag_adjustment : float, int .. versionadded:: 0.8 The amount of shift in mm to apply to the image to correct for EPID sag. For Up-Down picket images, positive moves the image down, negative up. For Left-Right picket images, positive moves the image left, negative right. """ if action_tolerance is not None and tolerance < action_tolerance: raise ValueError("Tolerance cannot be lower than the action tolerance") """Pre-analysis""" self.settings = Settings(self.orientation, tolerance, action_tolerance, hdmlc, self.image) self._adjust_for_sag(sag_adjustment) """Analysis""" self.pickets = PicketHandler(self.image, self.settings, num_pickets) def plot_analyzed_image(self, guard_rails=True, mlc_peaks=True, overlay=True, leaf_error_subplot=True, interactive=False, show=True): """Plot the analyzed image. Parameters ---------- guard_rails : bool Do/don't plot the picket "guard rails" around the ideal picket mlc_peaks : bool Do/don't plot the MLC positions. overlay : bool Do/don't plot the alpha overlay of the leaf status. leaf_error_subplot : bool .. versionadded:: 1.0 If True, plots a linked leaf error subplot adjacent to the PF image plotting the average and standard deviation of leaf error. interactive : bool .. versionadded:: 1.0 .. note:: mpld3 must be installed to use this feature. If False (default), plots a matplotlib figure. If True, plots a MPLD3 local server image, which adds some tooltips. """ # plot the image fig, ax = plt.subplots(figsize=self.settings.figure_size) ax.imshow(self.image.array, cmap=plt.cm.Greys) # generate a leaf error subplot if desired if leaf_error_subplot: self._add_leaf_error_subplot(ax, fig, interactive) # plot guard rails and mlc peaks as desired for p_num, picket in enumerate(self.pickets): if guard_rails: picket.add_guards_to_axes(ax.axes) if mlc_peaks: for idx, mlc_meas in enumerate(picket.mlc_meas): mlc_meas.add_to_axes(ax.axes, width=1.5) # plot the overlay if desired. if overlay: o = Overlay(self.image, self.settings, self.pickets) o.add_to_axes(ax) # tighten up the plot view ax.set_xlim([0, self.image.shape[1]]) ax.set_ylim([0, self.image.shape[0]]) ax.axis('off') if show: if interactive: mpld3 = import_mpld3() mpld3.show() else: plt.show() def _add_leaf_error_subplot(self, ax, fig, interactive): """Add a bar subplot showing the leaf error.""" tol_line_height = [self.settings.tolerance, self.settings.tolerance] tol_line_width = [0, max(self.image.shape)] # make the new axis divider = make_axes_locatable(ax) if self.settings.orientation == orientations['UD']: axtop = divider.append_axes('right', 2, pad=1, sharey=ax) else: axtop = divider.append_axes('bottom', 2, pad=1, sharex=ax) # get leaf positions, errors, standard deviation, and leaf numbers pos, vals, err, leaf_nums = self.pickets.error_hist() # plot the leaf errors as a bar plot if self.settings.orientation == orientations['UD']: axtop.barh(pos, vals, xerr=err, height=self.pickets[0].sample_width * 2, alpha=0.4, align='center') # plot the tolerance line(s) # TODO: replace .plot() calls with .axhline when mpld3 fixes funtionality axtop.plot(tol_line_height, tol_line_width, 'r-', linewidth=3) if self.settings.action_tolerance is not None: axtop.plot(tol_line_height, tol_line_width, 'y-', linewidth=3) # reset xlims to comfortably include the max error or tolerance value axtop.set_xlim([0, max(max(vals), self.settings.tolerance) + 0.1]) else: axtop.bar(pos, vals, yerr=err, width=self.pickets[0].sample_width * 2, alpha=0.4, align='center') axtop.plot(tol_line_width, tol_line_height, 'r-', linewidth=3) if self.settings.action_tolerance is not None: axtop.plot(tol_line_width, tol_line_height, 'y-', linewidth=3) axtop.set_ylim([0, max(max(vals), self.settings.tolerance) + 0.1]) # add formatting to axis axtop.grid('on') axtop.set_title("Average Error (mm)") # add tooltips if interactive if interactive: labels = [['Leaf pair {}/{}, Avg Error: {:3.3f}mm, Stdev: {:3.3f}mm'.format(leaf_num[0], leaf_num[1], err, std)] for leaf_num, err, std in zip(leaf_nums, vals, err)] mpld3 = import_mpld3() for num, patch in enumerate(axtop.axes.patches): ttip = mpld3.plugins.PointLabelTooltip(patch, labels[num], location='top left') mpld3.plugins.connect(fig, ttip) def save_analyzed_image(self, filename, guard_rails=True, mlc_peaks=True, overlay=True, leaf_error_subplot=False, interactive=False, **kwargs): """Save the analyzed figure to a file. See :meth:`~pylinac.picketfence.PicketFence.plot_analyzed_image()` for further parameter info. interactive : bool If False (default), saves the figure as a .png image. If True, saves an html file, which can be opened in a browser, etc. .. note:: mpld3 must be installed to use this feature. """ self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, leaf_error_subplot=leaf_error_subplot, interactive=interactive, show=False) if interactive: mpld3 = import_mpld3() mpld3.save_html(plt.gcf(), filename) else: plt.savefig(filename, **kwargs) if isinstance(filename, str): print("Picket fence image saved to: {}".format(osp.abspath(filename))) def return_results(self): """Return results of analysis. Use with print().""" pass_pct = self.percent_passing string = "Picket Fence Results: \n{:2.1f}% " \ "Passed\nMedian Error: {:2.3f}mm \n" \ "Max Error: {:2.3f}mm on Picket: {}, Leaf: {}".format(pass_pct, self.abs_median_error, self.max_error, self.max_error_picket, self.max_error_leaf) return string @property def orientation(self): """The orientation of the image, either Up-Down or Left-Right.""" # replace any dead pixels with median value temp_image = self.image.array.copy() temp_image[temp_image < np.median(temp_image)] = np.median(temp_image) # find "range" of 80 to 90th percentiles row_sum = np.sum(temp_image, 0) col_sum = np.sum(temp_image, 1) row80, row90 = np.percentile(row_sum, [80, 90]) col80, col90 = np.percentile(col_sum, [80, 90]) row_range = row90 - row80 col_range = col90 - col80 # The true picket side will have a greater difference in # percentiles than will the non-picket size. if row_range < col_range: orientation = orientations['LR'] else: orientation = orientations['UD'] return orientation