From 8a51582446185c5793a666af0348a797bbcfcfd6 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Thu, 29 Jan 2015 11:28:54 -0600 Subject: [PATCH] updated docs & added submodule API docs. Docstring tweaks. Dropped napoleon from requirements & bumped pydicom --- docs/source/cbct_docs.rst | 25 ++---- docs/source/conf.py | 3 + docs/source/core_modules.rst | 31 +++++++ docs/source/index.rst | 1 + docs/source/installation.rst | 2 +- docs/source/starshot_docs.rst | 10 +-- docs/source/vmat_docs.rst | 18 ++-- pylinac/cbct.py | 165 +++++++++++++++++++--------------- pylinac/core/profile.py | 4 +- pylinac/starshot.py | 81 ++++++++++------- pylinac/vmat.py | 73 ++++++++++----- requirements.txt | 5 +- 12 files changed, 254 insertions(+), 164 deletions(-) create mode 100644 docs/source/core_modules.rst diff --git a/docs/source/cbct_docs.rst b/docs/source/cbct_docs.rst index 623643edb..698586ad8 100644 --- a/docs/source/cbct_docs.rst +++ b/docs/source/cbct_docs.rst @@ -7,6 +7,7 @@ Overview -------- .. automodule:: pylinac.cbct + :no-members: Running the Demo ---------------- @@ -87,13 +88,12 @@ The CBCT module is based on the tests and values given in the CatPhan 504 Manual **Restrictions** - .. warning:: Analysis can catastrophically fail or give unreliable results if any Restriction is violated. + .. warning:: Analysis can fail or give unreliable results if any Restriction is violated. * The phantom used must be an unmodified CatPhan 504, as endorsed and supplied by Varian. * The phantom must have <0.5cm offset in the z (In-Out) direction (work to remove this is in the plans). - **Pre-Analysis** * **Determine image properties** -- Upon load, the image set is analyzed for its DICOM properties to determine mm/pixel @@ -148,54 +148,43 @@ The CBCT class uses several other classes. There are several Slices of Interest SoIs have a base class as well as specialized classes for each specific slice. .. autoclass:: pylinac.cbct.CBCT - :members: - :inherited-members: + :no-show-inheritance: Supporting Data Structure .. autoclass:: pylinac.cbct.Algo_Data - :members: + :no-show-inheritance: Slice Objects .. autoclass:: pylinac.cbct.HU_Slice - :members: .. autoclass:: pylinac.cbct.Base_HU_Slice - :members: .. autoclass:: pylinac.cbct.UNIF_Slice - :members: .. autoclass:: pylinac.cbct.GEO_Slice - :members: .. autoclass:: pylinac.cbct.SR_Slice - :members: .. autoclass:: pylinac.cbct.Locon_Slice - :members: .. autoclass:: pylinac.cbct.Slice - :members: + :no-show-inheritance: ROI Objects .. autoclass:: pylinac.cbct.HU_ROI - :members: .. autoclass:: pylinac.cbct.GEO_ROI - :members: .. autoclass:: pylinac.cbct.SR_Circle_ROI - :members: .. autoclass:: pylinac.cbct.ROI_Disk - :members: .. autoclass:: pylinac.cbct.ROI - :members: + :no-show-inheritance: .. autoclass:: pylinac.cbct.GEO_Line - :members: + diff --git a/docs/source/conf.py b/docs/source/conf.py index f5dcc090e..3c818a89a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -35,6 +35,8 @@ for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() +# TODO: replace above with: autodoc_mock_imports when RTD upgrades to v1.3; http://sphinx-doc.org/latest/ext/autodoc.html#confval-autodoc_mock_imports + # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. @@ -66,6 +68,7 @@ copyright = '2015, James Kerns' # Document both class docstring and __init__ docstring. See: http://sphinx-doc.org/ext/autodoc.html#confval-autoclass_content autoclass_content = 'both' +autodoc_default_flags = ['members', 'show-inheritance'] # See: http://sphinx-doc.org/latest/ext/autodoc.html#confval-autodoc_default_flags # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/source/core_modules.rst b/docs/source/core_modules.rst new file mode 100644 index 000000000..267833bc5 --- /dev/null +++ b/docs/source/core_modules.rst @@ -0,0 +1,31 @@ +========================== +Core Modules Documentation +========================== + +The following is the API documentation for the core modules of pylinac. These can be used +directly, or as the base for mixin classes or methods. + +Analysis Module +--------------- +.. automodule:: pylinac.core.analysis + :no-show-inheritance: +Image Module +------------ +.. automodule:: pylinac.core.image + +Geometry Module +--------------- +.. automodule:: pylinac.core.geometry + +IO Module +--------- +.. automodule:: pylinac.core.io + +Utilities Module +---------------- +.. automodule:: pylinac.core.utilities + +Decorators Module +----------------- +.. automodule:: pylinac.core.decorators + diff --git a/docs/source/index.rst b/docs/source/index.rst index eaaccefe3..3895dbe75 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -33,6 +33,7 @@ or jump right in by doing :ref:`installation` and then :ref:`getting_started`! starshot_docs vmat_docs cbct_docs + core_modules Indices and tables diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 713aea41b..1025c9817 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -40,7 +40,7 @@ Pylinac, as a scientific package, has fairly standard scientific dependencies (> * numpy >= 1.8 * scipy >= 0.13 * matplotlib >= 1.3.1; Image plotting -* pydicom >= 0.9.8; For reading DICOM files +* pydicom >= 0.9.9; For reading DICOM files * Pillow >= 2.5; For reading image files .. note:: diff --git a/docs/source/starshot_docs.rst b/docs/source/starshot_docs.rst index 7c02cbe35..ec2aaefa1 100644 --- a/docs/source/starshot_docs.rst +++ b/docs/source/starshot_docs.rst @@ -11,6 +11,7 @@ Overview -------- .. automodule:: pylinac.starshot + :no-members: Running the Demo ---------------- @@ -84,7 +85,7 @@ Algorithm **Restrictions** - .. warning:: Analysis can catastrophically fail or give unreliable results if any Restriction is violated. + .. warning:: Analysis can fail or give unreliable results if any Restriction is violated. * The center of the "star" must be in the central 1/3 of the image. * The radiation spokes must extend to both sides of the center. I.e. the spokes must not end at the center of the circle. @@ -123,14 +124,9 @@ API Documentation ----------------- .. autoclass:: pylinac.starshot.Starshot - :members: - :inherited-members: .. autoclass:: pylinac.starshot.StarProfile - :members: - :inherited-members: .. autoclass:: pylinac.starshot.Wobble - :members: - :inherited-members: + diff --git a/docs/source/vmat_docs.rst b/docs/source/vmat_docs.rst index 2734b812b..dcb4e08a0 100644 --- a/docs/source/vmat_docs.rst +++ b/docs/source/vmat_docs.rst @@ -7,6 +7,7 @@ Overview -------- .. automodule:: pylinac.vmat + :no-members: Running the Demo ---------------- @@ -90,7 +91,7 @@ The minimum needed to get going is to: dmlc_img = "C:/QA Folder/VMAT/dmlc_field.dcm" # load the images from the file path myvmat.load_image(open_img, im_type='open') - myvmat.load_image(mlc_img, im_type='mlc') + myvmat.load_image(dmlc_img, im_type='mlc') # *OR* @@ -131,7 +132,7 @@ The algorithm works like such: **Restrictions** - .. warning:: Analysis can catastrophically fail or give unreliable results if any Restriction is violated. + .. warning:: Analysis can fail or give unreliable results if any Restriction is violated. * The tests must be delivered using the DICOM RT plan files provided by Varian which follow the test layout of Jorgensen et al. * The images must be acquired with the EPID. @@ -145,6 +146,10 @@ The algorithm works like such: **Analysis** +.. note:: + Calculations tend to be lazy, computed only on demand. This represents a nominal analysis + where all calculations are performed. + * **Calculate sample boundaries & extract** -- Because the Jorgensen tests are always the same in terms of where the radiation gets delivered, these values are hardcoded as offsets from the center pixel. These values are then scaled with the image scaling factor determined above. The mean of the pixel values within @@ -166,14 +171,11 @@ API Documentation ----------------- .. autoclass:: pylinac.vmat.VMAT - :members: - :inherited-members: + :no-show-inheritance: .. autoclass:: pylinac.vmat.Segment - :members: - :inherited-members: + :no-show-inheritance: .. autoclass:: pylinac.vmat.Sample - :members: - :inherited-members: + diff --git a/pylinac/cbct.py b/pylinac/cbct.py index cb6ac73b4..a70e78cdc 100644 --- a/pylinac/cbct.py +++ b/pylinac/cbct.py @@ -14,6 +14,7 @@ import shutil import zipfile import time +import math import numpy as np from scipy import ndimage @@ -34,8 +35,8 @@ class Algo_Data: - """Data structure for retaining certain settings and information regarding the CBCT algorithm. - This class is filled out during CBCT image loading. For internal use only. + """Data structure for retaining certain settings and information regarding the CBCT algorithm and image data. + This class is populated during CBCT image loading. For internal use only. """ threshold = -800 # threshold when converting to binary image HU_slice_num = 0 @@ -59,11 +60,8 @@ def mm_per_pixel(self): return self.dicom_metadata.PixelSpacing[0] @lazyproperty - def scaling_ratio(self): - return 0.488 / self.dicom_metadata.PixelSpacing[0] - - @property def manufacturer(self): + """The linac manufacturer.""" return self.dicom_metadata.Manufacturer def set_slice_nums(self): @@ -121,13 +119,14 @@ def __init__(self, name, slice_array, angle, radius=None, dist_from_center=None) ---------- angle : int, float The angle of the ROI in degrees from the phantom center. + + .. warning:: + Be sure the enter the angle in degrees rather than radians! radius : int, float The radius of the ROI from the center of the phantom. dist_from_center : int, float The distance of the ROI from the phantom center. - .. warning:: Be sure the enter the angle in degrees rather than radians! - See Also -------- ROI : Further parameter info @@ -153,14 +152,17 @@ def set_center_via_phan_center(self, phan_cent_point): x_shift = np.cos(np.deg2rad(self.angle))*self.dist_from_center self.center = Point(phan_cent_point.x+x_shift, phan_cent_point.y+y_shift) - def get_roi_mask(self, outside=np.NaN): + def get_roi_mask(self, outside='NaN'): """Return a masked array of the ROI. Parameters ---------- - outside : {np.NaN, 0} + outside : {'NaN', 0} The value the elements of the mask are made of. """ + if math.isnan(float(outside)): + outside = np.NaN + # create mask mask = sector_mask(self.slice_array.shape, self.center, self.radius) # Apply mask to image @@ -239,17 +241,12 @@ def get_pass_fail_color(self, passed='blue', failed='red'): class Slice(metaclass=ABCMeta): - """A base class for analyzing specific slices of a CBCT dicom set.""" + """Abstract base class for analyzing specific slices of a CBCT dicom set.""" def __init__(self, algo_data): """ Parameters ---------- - images : numpy.ndarray - The CBCT image set as a 3D numpy array. - slice_num : int - Slice number of interest. - mode : {'mean', 'max', 'median'} - Mode of combining surrounding images to lower noise. + algo_data : Algo_Data """ self.image = np.ndarray # place-holder; should be overloaded by subclass self.ROIs = OrderedDict() @@ -277,13 +274,7 @@ def add_ROI(self, *ROIs): self.ROIs[roi.name] = roi def find_phan_center(self): - """Determine the location of the center of the phantom. - - Parameters - ---------- - threshold : int - The threshold to convert the image to B&W. - """ + """Determine the location of the center of the phantom.""" SOI_bw = self.image.convert2BW(self.algo_data.threshold, return_it=True) # convert slice to binary based on threshold SOI_bw = ndimage.binary_fill_holes(SOI_bw.pixel_array) # fill in air pockets to make one solid ROI SOI_labeled, num_roi = ndimage.label(SOI_bw) # identify the ROIs @@ -309,8 +300,9 @@ def scale_by_FOV(self): distances to the ROI, etc needs to be corrected for the wider FOV. """ + class Base_HU_Slice(Slice, metaclass=ABCMeta): - """Base class for the HU and Uniformity Slices. Subclass of Slice.""" + """Abstract base class for the HU and Uniformity Slices.""" def get_ROI_vals(self): """Return a dict of the HU values of the HU ROIs.""" @@ -366,13 +358,6 @@ def determine_phantom_roll(self): """Determine the "roll" of the phantom. This algorithm uses the two air bubbles in the HU slice and the resulting angle between them. - - Parameters - ---------- - threshold : int - The threshold to convert the image to B&W. - scaling_ratio : float - The ratio of the image size to the reference image size (512x512). """ # convert slice to logical SOI = self.image.convert2BW(self.algo_data.threshold, return_it=True) @@ -491,7 +476,7 @@ def calc_median_profile(self, roll_offset=0): Returns ------- - median profile : profile.Profile + median profile : core.profile.Profile A 1D Profile of the Line Pair regions. """ # extract the profile for each ROI (5 adjacent profiles) @@ -528,12 +513,6 @@ def _find_LP_peaks(self, profile): max_idxs : numpy.array Indices of peaks found. """ - - region_1_bound = 4000 # approximate index between 1st and 2nd LP regions - region_2_bound = 10500 # approximate index between 4th and 5th LP regions - # region_3_bound = 17500 # approximate index between 8th and 9th LP regions; after this, regions become very hard to distinguish - region_3_bound = 12300 # for head this can be 17500, but for thorax (low dose => low quality), we can only sample the first - # 5 line pairs accurately max_vals_1, max_idx_1 = profile.find_peaks(min_peak_distance=150, max_num_peaks=2, exclude_rt_edge=0.9, return_it=True) max_vals_2, max_idx_2 = profile.find_peaks(min_peak_distance=48, exclude_lt_edge=0.12, exclude_rt_edge=0.7, return_it=True) max_vals_3, max_idx_3 = profile.find_peaks(min_peak_distance=25, exclude_lt_edge=0.3, exclude_rt_edge=0.65, return_it=True) @@ -583,6 +562,13 @@ def _calc_MTF(self, max_vals, min_vals): Maximum and minimum values are calculated by averaging the pixel values of the peaks/valleys found. + Parameters + ---------- + max_vals : numpy.ndarray + An array of the maximum values of the SR profile. + min_vals : numpy.ndarray + An array of the minimum values of the SR profile. + References ---------- http://en.wikipedia.org/wiki/Transfer_function#Optics @@ -632,7 +618,7 @@ def get_MTF(self, percent=80): class GEO_ROI(ROI_Disk): - """A circle ROI, much like the HU ROI, but with methods to find the center of the geometric "node".""" + """A circular ROI, much like the HU ROI, but with methods to find the center of the geometric "node".""" def __init__(self, name, slice_array, angle, radius, dist_from_center): super().__init__(name, slice_array, angle, radius, dist_from_center) self.node_CoM = None # the node "Center-of-Mass" @@ -658,7 +644,7 @@ def _threshold_node(self): return bw_node def find_node_center(self): - """Find the center of the geometric node.""" + """Find the center of the geometric node within the ROI.""" bw_node = self._threshold_node() # label ROIs found labeled_arr, num_roi = ndimage.measurements.label(bw_node) @@ -722,12 +708,6 @@ class GEO_Slice(Slice): tolerance = 1 def __init__(self, algo_data): - """ - Parameters - ---------- - mm_per_pixel : float - The mm/pixel conversion value. - """ super().__init__(algo_data) self.scale_by_FOV() self.image = ImageObj(combine_surrounding_slices(self.algo_data.images, self.algo_data.HU_slice_num, mode='median')) @@ -773,7 +753,7 @@ def calc_node_centers(self): roi.find_node_center() def get_line_lengths(self): - """Return the lengths of the lines in mm. + """Return the lengths of the lines in **mm**. Returns ------- @@ -784,7 +764,7 @@ def get_line_lengths(self): @property def overall_passed(self): - """Boolean property of whether all the line lengths were within tolerance.""" + """Boolean property returning whether all the line lengths were within tolerance.""" # all() would be nice, but didn't seem to work elegantly for length in self.get_line_lengths().values(): if self.line_nominal_value + self.tolerance < length < self.line_nominal_value - self.tolerance: @@ -795,10 +775,30 @@ def overall_passed(self): class CBCT: """A class for loading and analyzing Cone-Beam CT DICOM files of a CatPhan 504 (Varian; Elekta 503 is being developed. Analyzes: Uniformity, Spatial Resolution, Image Scaling & HU Linearity. + + Attributes + ---------- + algo_data : Algo_Data + HU : HU_Slice + UN : UNIF_Slice + GEO : GEO_Slice + LOCON: LOCON_Slice + SR : SR_Slice + + Examples + -------- + Run the demo: + >>> mycbct = CBCT().run_demo() + + Typical session: + >>> cbct_folder = r"C:/QA/CBCT/June" + >>> mycbct = CBCT() + >>> mycbct.load_folder(cbct_folder) + >>> mycbct.analyze() + >>> print(mycbct.return_results()) + >>> mycbct.plot_analyzed_image() """ def __init__(self): - # The following attrs will be instances of the Algo_Data class - # and respective Slice subclasses self.algo_data = None self.HU = None self.UN = None @@ -812,7 +812,7 @@ def load_demo_images(self, cleanup=True): Parameters ---------- cleanup : bool - If True (default), removed the extracted demo files. + If True (default), delete the extracted demo files. If False, leaves extracted files in the demo folder. Useful if using the demo images repeatedly. """ @@ -824,7 +824,7 @@ def load_demo_images(self, cleanup=True): if not osp.isdir(demo_folder): shutil.unpack_archive(demo_zip, cbct_demo_dir) - filelist = self._retrieve_CT_images_from_folder(demo_folder) + filelist = self._get_CT_filenames_from_folder(demo_folder) self._load_files(filelist) # delete the unpacked demo folder if cleanup: @@ -848,16 +848,32 @@ def load_folder(self, folder): ---------- folder : str Path to the folder. + + Raises + ------ + NotADirectoryError : if folder str passed is not a valid directory. + FileNotFoundError : If no CT images are found in the folder """ # check that folder is valid if not osp.isdir(folder): raise NotADirectoryError("Path given was not a Directory/Folder") - filelist = self._retrieve_CT_images_from_folder(folder) + filelist = self._get_CT_filenames_from_folder(folder) self._load_files(filelist) def load_zip_file(self, zip_file): - """Load a CBCT dataset from a zip file.""" + """Load a CBCT dataset from a zip file. + + Parameters + ---------- + zip_file : str + Path to the zip file. + + Raises + ------ + FileExistsError : If zip_file passed was not a legitimate zip file. + FileNotFoundError : If no CT images are found in the folder + """ zip_folder, _ = osp.splitext(zip_file) if not zipfile.is_zipfile(zip_file): @@ -865,7 +881,7 @@ def load_zip_file(self, zip_file): else: shutil.unpack_archive(zip_file, osp.dirname(zip_file)) - filelist = self._retrieve_CT_images_from_folder(zip_folder) + filelist = self._get_CT_filenames_from_folder(zip_folder) self._load_files(filelist) # delete the unpacked folder @@ -875,8 +891,18 @@ def load_zip_file(self, zip_file): print("Extracted demo images were not able to be deleted. You can manually delete them if you " "like from %s" % zip_folder) - def _retrieve_CT_images_from_folder(self, folder): - """Walk through a folder to find DICOM CT images.""" + def _get_CT_filenames_from_folder(self, folder): + """Walk through a folder to find DICOM CT images. + + Parameters + ---------- + folder : str + Path to the folder in question. + + Raises + ------ + FileNotFoundError : If no CT images are found in the folder + """ for par_dir, sub_dir, files in os.walk(folder): filelist = [osp.join(par_dir, item) for item in files if item.endswith('.dcm') and item.startswith('CT')] if filelist: @@ -1017,8 +1043,7 @@ def plot_analyzed_image(self, show=True): plt.show() def return_results(self): - """Return and print the results of the analysis as a string.""" - + """Return the results of the analysis as a string.""" #TODO: make prettier string = ('\n - CBCT QA Test - \n' 'HU Regions: {}\n' @@ -1032,7 +1057,6 @@ def return_results(self): self.GEO.get_line_lengths(), self.GEO.overall_passed) return string - def analyze(self): """Single-method full analysis of CBCT DICOM files.""" if not self.images_loaded: @@ -1044,7 +1068,7 @@ def analyze(self): self.construct_Locon() def run_demo(self, show=True): - """Run the CBCT demo using the high-quality head protocol.""" + """Run the CBCT demo using high-quality head protocol images.""" cbct = CBCT() cbct.load_demo_images() cbct.analyze() @@ -1053,6 +1077,7 @@ def run_demo(self, show=True): @property def images_loaded(self): + """Boolean property specifying if the images have been loaded.""" if self.algo_data is None: return False else: @@ -1076,7 +1101,7 @@ def combine_surrounding_slices(slice_array, nominal_slice_num, slices_plusminus= Returns ------- comb_slice : numpy.array - A slice the same size in the first to dimensions of im_array, combined. + An array the same size in the first two dimensions of im_array, combined. """ slices = slice_array[:,:,nominal_slice_num-slices_plusminus:nominal_slice_num+slices_plusminus] if mode == 'mean': @@ -1092,12 +1117,12 @@ def combine_surrounding_slices(slice_array, nominal_slice_num, slices_plusminus= # CBCT Demo # ---------------------------------------- if __name__ == '__main__': - # CBCT().run_demo() - zip_file = r"D:\Users\James\Dropbox\Programming\Python\Projects\PyCharm Projects\pylinac\tests\test_files\CBCT\Varian\Low dose thorax.zip" - cbct = CBCT() - cbct.load_zip_file(zip_file) + CBCT().run_demo() + # zip_file = r"D:\Users\James\Dropbox\Programming\Python\Projects\PyCharm Projects\pylinac\tests\test_files\CBCT\Varian\Low dose thorax.zip" + # cbct = CBCT() + # cbct.load_zip_file(zip_file) # cbct.load_demo_images() # cbct.algo_data.images = np.roll(cbct.algo_data.images, 30, axis=1) - cbct.analyze() - print(cbct.return_results()) - cbct.plot_analyzed_image() + # cbct.analyze() + # print(cbct.return_results()) + # cbct.plot_analyzed_image() diff --git a/pylinac/core/profile.py b/pylinac/core/profile.py index 5ec6b8720..63f4d65ab 100644 --- a/pylinac/core/profile.py +++ b/pylinac/core/profile.py @@ -144,6 +144,8 @@ def find_FWXM_peaks(self, fwxm=70, min_peak_height=0.3, min_peak_distance=10, ma """ self.find_peaks(min_peak_height, min_peak_distance, max_num_peaks) + if not self.peaks: + raise AttributeError("No peaks were found; try lowering the minimum peak height or use a different region.") subprofiles = self._subdivide_profiles() # update peak points with modified indices @@ -334,7 +336,7 @@ def __init__(self, y_values, x_values=None, normalize_sides=True, initial_peak=N self.ymax_left = np.max(self.ydata_left) self.ymax_right = np.max(self.ydata_right) - def _get_initial_peak(self, initial_peak, exclusion_region=0.2): + def _get_initial_peak(self, initial_peak, exclusion_region=0.1): """Determine an initial peak to use as a rough guideline. Parameters diff --git a/pylinac/starshot.py b/pylinac/starshot.py index f2a924499..b5e13131e 100644 --- a/pylinac/starshot.py +++ b/pylinac/starshot.py @@ -21,6 +21,26 @@ class Starshot(AnalysisModule): """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 : core.image.ImageObj + circle_profile : StarProfile + lines : list of Line instances + wobble : Wobble + + Examples + -------- + Run the demo: + >>> Starshot().run_demo() + + Typical session: + >>> img_path = r"C:/QA/Starshots/Coll" + >>> mystar = Starshot() + >>> mystar.load_image(img_path) + >>> mystar.analyze() + >>> print(mystar.return_results()) + >>> mystar.plot_analyzed_image() """ def __init__(self): super().__init__() @@ -28,11 +48,11 @@ def __init__(self): 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 = 1 # tolerance limit of the radiation wobble - self.tolerance_unit = 'pixels' # tolerance units are initially pixels. Will be converted to 'mm' if conversion + self._tolerance = 1 # tolerance limit of the radiation wobble + self._tolerance_unit = 'pixels' # tolerance units are initially pixels. Will be converted to 'mm' if conversion # information available in image properties - def load_demo_image(self, cleanup=True): + def load_demo_image(self): """Load the starshot demo image. The Pylinac package comes with compressed demo images. @@ -108,15 +128,9 @@ def clear_start_point(self): """Clear/reset the algorithm starting point.""" self.circle_profile.center = Point() - def _check_image_inversion(self, allow_inversion=True): + def _check_image_inversion(self): """Check the image for proper inversion, i.e. that pixel value increases with dose. - Parameters - ---------- - allow_inversion : boolean - If True (default), the image inversion is allowed to happen. - If False, no inversion is made to the image and is returned as-is. - Notes ----- Inversion is checked by the following: @@ -124,8 +138,6 @@ def _check_image_inversion(self, allow_inversion=True): - 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. """ - if not allow_inversion: - return # sum the image along each axis x_sum = np.sum(self.image.pixel_array, 0) @@ -163,7 +175,7 @@ def _auto_set_start_point(self): self.set_start_point(center_point, warn_if_far_away=False) @value_accept(radius=(0.05, 0.95), min_peak_height=(0.1, 0.9), SID=(0, 180)) - def analyze(self, radius=0.5, min_peak_height=0.25, SID=100, allow_inversion=True): + def analyze(self, radius=0.5, min_peak_height=0.25, SID=100): """Analyze the starshot image. Analyze finds the minimum radius and center of a circle that touches all the lines @@ -181,8 +193,6 @@ def analyze(self, radius=0.5, min_peak_height=0.25, SID=100, allow_inversion=Tru 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)]. - allow_inversion : boolean, optional - Specifies whether to let the algorithm automatically check the image for proper inversion. Recommend True. Raises ------ @@ -194,7 +204,7 @@ def analyze(self, radius=0.5, min_peak_height=0.25, SID=100, allow_inversion=Tru raise AttributeError("Starshot image not yet loaded") # check inversion - self._check_image_inversion(allow_inversion) + self._check_image_inversion() # set starting point automatically if not yet set if not self.start_point_is_set: @@ -223,7 +233,7 @@ def _convert_radius_perc2pix(self, radius): @property def image_is_loaded(self): - """Boolean specifying if an image has been loaded.""" + """Boolean property specifying if an image has been loaded.""" if self.image.pixel_array.size == 0: return False else: @@ -247,10 +257,10 @@ def _scale_wobble(self, SID): """ # convert wobble to mm if possible if self.image.dpmm != 0: - self.tolerance_unit = 'mm' + self._tolerance_unit = 'mm' self.wobble.radius_mm = self.wobble.radius / self.image.dpmm else: - self.tolerance_unit = 'pixels' + self._tolerance_unit = 'pixels' self.wobble.radius_mm = self.wobble.radius self.wobble.radius /= SID / 100 @@ -264,6 +274,10 @@ def _find_wobble_2step(self, SID): 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 = self.circle_profile.center @@ -315,7 +329,7 @@ def _find_wobble(self, tolerance, start_point, scale): @property def passed(self): """Boolean specifying whether the determined wobble was within tolerance.""" - if self.wobble.radius_mm * 2 < self.tolerance: + if self.wobble.radius_mm * 2 < self._tolerance: return True else: return False @@ -335,7 +349,7 @@ def return_results(self): 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, + '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 @@ -385,19 +399,19 @@ def run_demo(self, show=True): class Wobble(Circle): - """A class that holds the wobble information of the Starshot analysis.""" + """A class that holds the wobble information of the Starshot analysis. + + Attributes + ---------- + radius_mm : The radius of the Circle in **mm**. + """ def __init__(self, center_point=None, radius=None): super().__init__(center_point=center_point, radius=radius) self.radius_mm = 0 # The radius of the wobble in mm; as opposed to pixels. class StarProfile(CircleProfile): - """Class that holds and analyzes the circular profile which finds the radiation lines. - - See Also - -------- - profile.CircleProfile() - """ + """Class that holds and analyzes the circular profile which finds the radiation lines.""" def __init__(self): super().__init__() @@ -406,7 +420,7 @@ def get_profile(self, image_array): See Also -------- - profile.CircleProfile.get_profile() : Further parameter info + core.profile.CircleProfile.get_profile : Further parameter info """ super().get_profile(image_array) self._roll_prof_to_midvalley() @@ -436,10 +450,10 @@ def find_rad_lines(self, min_peak_height, min_peak_distance=0.04): See Also -------- - Starshot.analyze() : Further parameter info + Starshot.analyze() : min_peak_height parameter info + core.profile.CircleProfile.find_FWXM_peaks : min_peak_distance parameter info. geometry.Line : returning object """ - # find the FWHM-C self.find_FWXM_peaks(min_peak_height=min_peak_height, min_peak_distance=min_peak_distance) @@ -464,4 +478,7 @@ def match_peaks(self): # Starshot demo # ---------------------------- if __name__ == '__main__': - Starshot().run_demo() \ No newline at end of file + Starshot().run_demo() + # star = Starshot() + # star.load_demo_image() + # star.analyze(radius=0.9) \ No newline at end of file diff --git a/pylinac/vmat.py b/pylinac/vmat.py index 028b789b9..8b13b9e59 100644 --- a/pylinac/vmat.py +++ b/pylinac/vmat.py @@ -22,14 +22,40 @@ class VMAT: """The VMAT class analyzes two DICOM images acquired via a linac's EPID and analyzes regions of interest (segments) based on the paper by `Jorgensen et al `_, specifically, the Dose Rate & Gantry Speed (DRGS) and Dose Rate & MLC speed (DRMLC) tests. - """ + Examples + -------- + Run the DRGS demo: + >>> VMAT().run_demo_drgs() + + Run the DRMLC demo: + >>> VMAT().run_demo_drmlc() + + A typical use case: + >>> open_img = "C:/QA Folder/VMAT/open_field.dcm" + >>> dmlc_img = "C:/QA Folder/VMAT/dmlc_field.dcm" + >>> myvmat = VMAT() + >>> myvmat.load_image(open_img, im_type='open') + >>> myvmat.load_image(dmlc_img, im_type='mlc') + >>> myvmat.analyze(test='drmlc', tolerance=3, HDMLC=False) + >>> print(myvmat.return_results()) + >>> myvmat.plot_analyzed_image() + + Attributes + ---------- + image_open : :class:`ImageObj` + The open-field image object. + image_dmlc : core.image.ImageObj + The dmlc-field image object. + segments : list + A list containing :class:`Segment` instances, which contain :class:`Sample` instances. + """ def __init__(self): super().__init__() self.image_open = ImageObj() # the Open field image self.image_dmlc = ImageObj() # the MLC field image self._test_type = '' # the test to perform - self.tolerance = 3 # default of 3% tolerance as Jorgensen recommends + self._tolerance = 3 # default of 3% tolerance as Jorgensen recommends self.segments = [] # a list which will hold Segment objects (either 4 or 7) @value_accept(im_type=im_types) @@ -87,21 +113,21 @@ def load_demo_image(self, test_type='drgs'): self.load_image(im_open_path, im_type=im_types['OPEN']) self.load_image(im_dmlc_path, im_type=im_types['DMLC']) - def run_demo_drgs(self, show=True): + def run_demo_drgs(self, tolerance=3, show=True): """Run the VMAT demo for the Dose Rate & Gantry Speed test.""" self.load_demo_image('drgs') - self.analyze(test='drgs', tolerance=3) # set tolerance to 2 to show some failures + self.analyze(test='drgs', tolerance=tolerance) # set tolerance to 2 to show some failures print(self.return_results()) self.plot_analyzed_image(show=show) - def run_demo_drmlc(self, show=True): + def run_demo_drmlc(self, tolerance=3, show=True): """Run the VMAT demo for the Dose Rate & MLC speed test.""" self.load_demo_image('drmlc') - self.analyze(test='drmlc', tolerance=3) + self.analyze(test='drmlc', tolerance=tolerance) print(self.return_results()) self.plot_analyzed_image(show=show) - def _calc_im_scaling_factors(self, SID=None): + def _calc_im_scaling_factors(self): """Determine image scaling factors. Factors are relative to reference values from images of size 384x512 taken at 150cm SID. @@ -118,8 +144,6 @@ def _calc_im_scaling_factors(self, SID=None): # SID scaling if self.image_open.SID: SID_scale = self.image_open.SID / 150.0 - elif SID is not None: - SID_scale = SID / 150.0 else: SID_scale = 1 @@ -185,8 +209,8 @@ def _calc_deviations(self): segment.deviations = deviation[:, segment_num] @type_accept(test=str) - @value_accept(test=test_types, tolerance=(0.3, 8), SID=(0, 180)) - def analyze(self, test, tolerance=3, SID=None, HDMLC=False): + @value_accept(test=test_types, tolerance=(0.3, 8)) + def analyze(self, test, tolerance=3, HDMLC=False): """Analyze the open and DMLC field VMAT images, according to 1 of 2 possible tests. Parameters @@ -196,9 +220,6 @@ def analyze(self, test, tolerance=3, SID=None, HDMLC=False): tolerance : float, int, optional The tolerance of the sample deviations in percent. Default is 3, as Jorgensen recommends. Must be between 0.3 and 8. - SID : int, None, optional - The Source to Image (detector) distance in cm. Usually doesn't need to be passed for EPID DICOM images. This argument - will override any automatically derived value however. If left as None and no SID was determined, it will assume 150cm. HDMLC : boolean Flag specifying if the linac has a regular (5mm central leaf width) MLC set, or HD set (2.5mm). """ @@ -208,11 +229,11 @@ def analyze(self, test, tolerance=3, SID=None, HDMLC=False): self._check_img_inversion() - self.tolerance = tolerance / 100.0 + self._tolerance = tolerance / 100.0 self._test_type = test # get the image scaling factors and center pixels; this corrects for the SID - SID_scale, scale = self._calc_im_scaling_factors(SID) + SID_scale, scale = self._calc_im_scaling_factors() # set up pixel bounds of test self.construct_segments(test, scale, SID_scale, HDMLC) @@ -247,7 +268,7 @@ def sample_pass_matrix(self): # TODO: probably replacable with np.where() for seg_num, segment in enumerate(self.segments): for sam_num, sample in enumerate(segment.samples): - if sample.ratio < 1 + self.tolerance and sample.ratio > 1 - self.tolerance: + if sample.ratio < 1 + self._tolerance and sample.ratio > 1 - self._tolerance: sample_passfail_matrix[sam_num, seg_num] = True return sample_passfail_matrix @@ -392,10 +413,10 @@ def return_results(self): if self._test_type == test_types['DRGS']: string = ('Dose Rate & Gantry Speed \nTest Results (Tol. +/-%2.1f%%): %s\n' % - (self.tolerance * 100, passfail_str)) + (self._tolerance * 100, passfail_str)) elif self._test_type == test_types['DRMLC']: string = ('Dose Rate & MLC Speed \nTest Results (Tol. +/-%2.1f%%): %s\n' % - (self.tolerance * 100, passfail_str)) + (self._tolerance * 100, passfail_str)) string += ('\nOverall Results:\n' 'Max Positive Deviation: %4.3f%%\n' @@ -421,6 +442,11 @@ def return_results(self): class Sample(Rectangle): """Represents a single 'sample' of a VMAT segment. A sample is an ROI of the radiation one MLC pair produces in one VMAT segment. + + Attributes + ---------- + ratio : float + The ratio of the open field pixels to the DMLC field pixels within the Sample ROI. """ def __init__(self, width, height, center): """ @@ -430,7 +456,7 @@ def __init__(self, width, height, center): Width of the sample in pixels. height : int Height of the sample in pixels. - center : geometry.Point + center : core.geometry.Point Center Point of the sample. """ super().__init__(width, height, center, as_int=True) @@ -559,10 +585,9 @@ def _ratios(self): return np.array([sample.ratio for sample in self.samples]) - -#--------------------------------------------------------------------------------------------------------------------- -# VMAT demo. -#--------------------------------------------------------------------------------------------------------------------- +# ------------------- +# VMAT demo +# ------------------- if __name__ == '__main__': # VMAT().run_demo_drgs() VMAT().run_demo_drmlc() # uncomment to run MLCS demo diff --git a/requirements.txt b/requirements.txt index e7155b5a4..3afa4a5e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ numpy >= 1.8 scipy >= 0.13 -pydicom >= 0.9.8 +pydicom >= 0.9.9 matplotlib >= 1.3.1 -Pillow >= 2.5 -sphinxcontrib-napoleon \ No newline at end of file +Pillow >= 2.5 \ No newline at end of file