Skip to content

Commit

Permalink
Closes #307 for all except flatsym.
Browse files Browse the repository at this point in the history
  • Loading branch information
jrkerns committed Apr 6, 2021
1 parent 2c319a9 commit cca3dc8
Show file tree
Hide file tree
Showing 12 changed files with 187 additions and 18 deletions.
57 changes: 56 additions & 1 deletion pylinac/ct.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .core.roi import DiskROI, RectangleROI, LowContrastDiskROI
from .core.typing import NumberLike
from .settings import get_dicom_cmap
from . import __version__


AIR = -1000
Expand Down Expand Up @@ -210,7 +211,7 @@ def __init__(self, catphan, tolerance: float, offset: int=0):
self.slice_thickness = catphan.dicom_stack.metadata.SliceThickness
self.catphan_roll = catphan.catphan_roll
self.mm_per_pixel = catphan.mm_per_pixel
self.rois = {} # dicts of HUDiskROIs
self.rois: Dict[HUDiskROI] = {} # dicts of HUDiskROIs
self.background_rois = {} # dict of HUDiskROIs; possibly empty
Slice.__init__(self, catphan, combine_method=self.combine_method, num_slices=self.num_slices)
self._convert_units_in_settings()
Expand Down Expand Up @@ -784,10 +785,12 @@ def rois_visible(self) -> int:

@property
def lower_window(self) -> float:
"""Lower bound of CT window/leveling to show on the plotted image. Improves apparent contrast."""
return Enumerable(self.background_rois.values()).min(lambda r: r.pixel_value) - self.WINDOW_SIZE

@property
def upper_window(self) -> float:
"""Upper bound of CT window/leveling to show on the plotted image. Improves apparent contrast"""
return Enumerable(self.rois.values()).max(lambda r: r.pixel_value) + self.WINDOW_SIZE


Expand Down Expand Up @@ -1284,6 +1287,58 @@ def results(self) -> str:
string += add
return string

def results_data(self) -> Dict:
"""Return the results of the analysis as a dict. Useful for accessing data in a consistent manner."""
data = dict()
data['pylinac version'] = __version__
data['General info'] = {'CatPhan model': self._model,
'CatPhan roll (degrees)': self.catphan_roll,
'Origin slice (CTP404)': self.origin_slice,
'Num images': self.num_images,
'Modules': [{mod.attr_name: offset} for mod, offset in self.modules.items()]}

# CTP 404 HU stuff
data['CTP404 HU ROI settings'] = self.ctp404.roi_settings
data['CTP404 HU ROI background settings'] = self.ctp404.background_roi_settings
data['CTP404 HU ROI values'] = [{name: {'avg value': val.pixel_value, 'cnr': val.cnr,
'difference': val.value_diff, 'nominal value': val.nominal_val,
'passed': val.passed}} for name, val in self.ctp404.rois.items()]
data['CTP404 HU tolerance (HU)'] = self.ctp404.hu_tolerance
data['CTP404 HU linearity passed?'] = self.ctp404.passed_hu

# CTP 404 Geometry stuff
data['CTP404 Geometry ROI analysis size setting (mm)'] = self.ctp404.geometry_roi_size_mm
data['CTP404 Geometry ROI distances (mm)'] = [{name: l.length_mm} for name, l in self.ctp404.lines.items()]
data['CTP404 Geometry AVG distance (mm)'] = self.ctp404.avg_line_length
data['CTP404 Geometry passed?'] = self.ctp404.passed_geometry

# CTP 404 Thickness stuff
data['CTP404 Thickness ROI settings'] = self.ctp404.thickness_roi_settings
data['CTP404 Thickness # slices combined'] = self.ctp404.num_slices + self.ctp404.pad
data['CTP404 Thickness measured (mm)'] = self.ctp404.meas_slice_thickness
data['CTP404 Thickness passed?'] = self.ctp404.passed_thickness
data['CTP404 Thickness LCV'] = self.ctp404.lcv

# CTP 486 Uniformity stuff
if self._has_module(CTP486):
data['CTP486 Uniformity ROI settings'] = self.ctp486.roi_settings
data['CTP486 Uniformity index'] = self.ctp486.uniformity_index
data['CTP486 Uniformity integral non-uniformity'] = self.ctp486.integral_non_uniformity
data['CTP486 Uniformity passed?'] = self.ctp486.overall_passed

# CTP 528 stuff
if self._has_module(CTP528CP504):
data['CTP528 Spatial Resolution ROI settings'] = self.ctp528.roi_settings
data['CTP528 Spatial Resolution start angle (radians)'] = self.ctp528.start_angle
data['CTP528 Spatial Resolution rMTFs (lp/mm)'] = [{p: self.ctp528.mtf.relative_resolution(p)} for p in (80, 50, 30)]

# CTP 515 stuff
if self._has_module(CTP515):
data['CTP515 Low Contrast CNR threshold'] = self.ctp515.cnr_threshold
data['CTP515 Low Contrast # seen'] = self.ctp515.rois_visible
data['CTP515 Low Contrast ROI settings'] = self.ctp515.roi_settings
return data


class CatPhan503(CatPhanBase):
"""A class for loading and analyzing CT DICOM files of a CatPhan 503.
Expand Down
16 changes: 16 additions & 0 deletions pylinac/picketfence.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .core.utilities import open_path
from .log_analyzer import load_log
from .settings import get_dicom_cmap
from . import __version__

# possible orientations of the pickets.
UP_DOWN = 'Up-Down'
Expand Down Expand Up @@ -546,6 +547,21 @@ def results(self) -> str:
f"Max Error: {self.max_error:2.3f}mm on Picket: {self.max_error_picket}, Leaf: {self.max_error_leaf}"
return string

def results_data(self) -> dict:
"""Present the results data and metadata as a dict."""
data = dict()
data['pylinac version'] = __version__
data['PF tolerance'] = self.tolerance
data['PF action tolerance'] = self.action_tolerance
data['PF % passing'] = self.percent_passing
data['PF # pickets'] = self.num_pickets
data['PF abs median error (mm)'] = self.abs_median_error
data['PF max error (mm)'] = self.max_error
data['PF mean picket spacing (mm)'] = self.mean_picket_spacing
data['PF offsets from CAX (mm)'] = [pk.dist2cax for pk in self.pickets]
data["PF passed?"] = self.passed
return data

def publish_pdf(self, filename: str, notes: str = None, open_file: bool = False, metadata: dict = None) -> None:
"""Publish (print) a PDF containing the analysis, images, and quantitative results.
Expand Down
15 changes: 15 additions & 0 deletions pylinac/planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from .core.roi import LowContrastDiskROI, HighContrastDiskROI, bbox_center
from .core import pdf
from .core import geometry
from . import __version__


class ImagePhantomBase:
Expand Down Expand Up @@ -368,6 +369,20 @@ def results(self) -> str:
]
return text

def results_data(self) -> dict:
data = dict()
data['pylinac version'] = __version__
data['Planar phantom analysis type'] = self.common_name
data['Planar median contrast'] = np.median([roi.contrast for roi in self.low_contrast_rois])
data['Planar median CNR'] = np.median([roi.contrast_to_noise for roi in self.low_contrast_rois])
data['Planar # contrast ROIs seen'] = sum(roi.passed for roi in self.low_contrast_rois)
data['Planar phantom center (px)'] = {'x': self.phantom_center.x, 'y': self.phantom_center.y}
data['Planar phantom low contrast ROI settings'] = self.low_contrast_roi_settings
if self.mtf is not None:
data['Planar phantom high contrast ROI settings'] = self.high_contrast_roi_settings
data['Planar rMTF (%:lp/mm)'] = [{p: self.mtf.relative_resolution(p)} for p in (80, 50, 30)]
return data

def publish_pdf(self, filename: str, notes: str=None, open_file: bool=False, metadata: Optional[dict]=None):
"""Publish (print) a PDF containing the analysis, images, and quantitative results.
Expand Down
12 changes: 12 additions & 0 deletions pylinac/starshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .core.profile import SingleProfile, CollapsedCircleProfile
from .core.utilities import open_path
from .settings import get_dicom_cmap
from . import __version__


class Starshot:
Expand Down Expand Up @@ -316,6 +317,17 @@ def results(self) -> str:
f'The center of the minimum circle is at {self.wobble.center.x:3.1f}, {self.wobble.center.y:3.1f}')
return string

def results_data(self) -> dict:
"""Return the analysis data as a dict."""
data = dict()
data['pylinac version'] = __version__
data['Starshot tolerance (mm)'] = self.tolerance
data['Starshot circle diameter (mm)'] = self.wobble.radius_mm*2
data['Starshot circle radius (mm)'] = self.wobble.radius_mm
data['Starshot circle center (px)'] = {'x': self.wobble.center.x, 'y': self.wobble.center.y}
data['Starshot passed?'] = self.passed
return data

def plot_analyzed_image(self, show: bool=True):
"""Draw the star lines, profile circle, and wobble circle on a matplotlib figure.
Expand Down
13 changes: 13 additions & 0 deletions pylinac/vmat.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .core.profile import SingleProfile
from .core.utilities import open_path
from .settings import get_dicom_cmap
from . import __version__

DMLC = 'dmlc'
OPEN = 'open'
Expand Down Expand Up @@ -151,6 +152,18 @@ def results(self) -> str:
string += f'Max Deviation: {self.max_r_deviation:2.3}%\nAbsolute Mean Deviation: {self.avg_abs_r_deviation:2.3}%'
return string

def results_data(self) -> dict:
"""Analysis and metadata as a dict"""
data = dict()
data['pylinac version'] = __version__
data['VMAT test'] = self._result_header
data['VMAT tolerance (%)'] = self._tolerance*100
data['VMAT max deviation (%)'] = self.max_r_deviation
data['VMAT abs mean deviation (%)'] = self.avg_abs_r_deviation
data['VMAT segment X positions (mm)'] = self.SEGMENT_X_POSITIONS_MM
data['VMAT passed?'] = self.passed
return data

def _calculate_segment_centers(self) -> List[Point]:
"""Construct the center points of the segments based on the field center and known x-offsets."""
points = []
Expand Down
45 changes: 30 additions & 15 deletions pylinac/winston_lutz.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .core.mask import bounding_box
from .core import pdf
from .core.utilities import is_close, open_path
from . import __version__

GANTRY = 'Gantry'
COLLIMATOR = 'Collimator'
Expand Down Expand Up @@ -543,22 +544,36 @@ def results(self, as_list: bool=False) -> str:
result = '\n'.join(result)
return result

def results_data(self):
def results_data(self) -> dict:
"""Return the analysis results as a dictionary."""
return_dict = {}
return_dict['cax2bb max'] = self.cax2bb_distance('max')
return_dict['cax2bb median'] = self.cax2bb_distance('median')
return_dict['cax2epid max'] = self.cax2epid_distance('max')
return_dict['cax2epid median'] = self.cax2epid_distance('median')
return_dict['coll iso size'] = self.collimator_iso_size
return_dict['couch iso size'] = self.couch_iso_size
return_dict['gantry iso size'] = self.gantry_iso_size
return_dict['gantry coll iso size'] = self.gantry_coll_iso_size
return_dict['MechRad x'] = -1 *self.bb_shift_vector.x
return_dict['MechRad y'] = -1 * self.bb_shift_vector.y
return_dict['MechRad z'] = -1 * self.bb_shift_vector.z
return_dict['axis rms dev'] = self.axis_rms_deviation
return return_dict
num_gantry_imgs = self._get_images(axis=(GANTRY, REFERENCE))[0]
num_gantry_coll_imgs = self._get_images(axis=(GANTRY, COLLIMATOR, GB_COMBO, REFERENCE))[0]
num_coll_imgs = self._get_images(axis=(COLLIMATOR, REFERENCE))[0]
num_couch_imgs = self._get_images(axis=(COUCH, REFERENCE))[0]

data = dict()
data['pylinac version'] = __version__

data['WL # of images'] = len(self.images)
data['WL CAX->BB 2D max (mm)'] = self.cax2bb_distance('max')
data['WL CAX->BB 2D median (mm)'] = self.cax2bb_distance('median')
data['WL CAX->EPID 2D max (mm)'] = self.cax2epid_distance('max')
data['WL CAX->EPID 2D median (mm)'] = self.cax2epid_distance('median')
data['WL Collimator 2D iso size (mm)'] = self.collimator_iso_size
data['WL Collimator RMS deviations (mm)'] = self.axis_rms_deviation(axis=COLLIMATOR)
data['WL # Coll images considered'] = num_coll_imgs
data['WL Couch 2D iso size (mm)'] = self.couch_iso_size
data['WL Couch RMS deviations (mm)'] = self.axis_rms_deviation(axis=COUCH)
data['WL # Couch images considered'] = num_couch_imgs
data['WL Gantry 3D iso size (mm)'] = self.gantry_iso_size
data['WL Gantry RMS deviations (mm)'] = self.axis_rms_deviation(axis=GANTRY)
data['WL # Gantry images considered'] = num_gantry_imgs
data['WL Gantry+Coll 3D iso size (mm)'] = self.gantry_coll_iso_size
data['WL # Gantry+Coll images considered'] = num_gantry_coll_imgs
data['WL BB shift instructions'] = self.bb_shift_instructions()
data['WL BB 3D position from Iso'] = {'x': self.bb_shift_vector.x, 'y': self.bb_shift_vector.y, 'z': self.bb_shift_vector.z}
data['WL Iso 3D position from BB'] = {'x': -self.bb_shift_vector.x, 'y': -self.bb_shift_vector.y, 'z': -self.bb_shift_vector.z}
return data

def publish_pdf(self, filename: str, notes: Optional[Union[str, List[str]]]=None, open_file: bool=False, metadata: Optional[dict]=None):
"""Publish (print) a PDF containing the analysis, images, and quantitative results.
Expand Down
8 changes: 8 additions & 0 deletions tests_basic/test_cbct.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ def test_phan_center(self):
self.assertAlmostEqual(self.cbct.ctp404.phan_center.x, known_phan_center.x, delta=0.7)
self.assertAlmostEqual(self.cbct.ctp404.phan_center.y, known_phan_center.y, delta=0.7)

def test_results_data(self):
self.cbct.analyze()
data = self.cbct.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 26)
self.assertIn('pylinac version', data)
self.assertEqual(data['CTP404 HU tolerance (HU)'], self.cbct.ctp404.hu_tolerance)


class CustomPhantom(TestCase):

Expand Down
6 changes: 6 additions & 0 deletions tests_basic/test_picketfence.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ def test_publish_pdf(self):
with tempfile.TemporaryFile() as t:
self.pf.publish_pdf(t, notes='stuff', metadata={'Unit': 'TB1'})

def test_results_data(self):
data = self.pf.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 10)
self.assertIn('pylinac version', data)
self.assertEqual(data['PF max error (mm)'], self.pf.max_error)

class TestPlottingSaving(TestCase):

Expand Down
9 changes: 9 additions & 0 deletions tests_basic/test_planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ def test_overrides(self):
phan = DoselabMC2kV.from_demo_image()
phan.analyze(angle_override=44, center_override=(500, 500), size_override=50)

def test_results_data(self):
phan = LeedsTOR.from_demo_image()
phan.analyze()
data = phan.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 9)
self.assertIn('pylinac version', data)
self.assertEqual(data['Planar phantom center (px)'], {'x': phan.phantom_center.x, 'y': phan.phantom_center.y})


class PlanarPhantomMixin(LocationMixin):
klass = object
Expand Down
7 changes: 7 additions & 0 deletions tests_basic/test_starshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,10 @@ def test_publish_pdf(self):
with tempfile.TemporaryFile() as t:
self.star.publish_pdf(t, notes='stuff', metadata={"Unit": 'TB1'})

def test_results_data(self):
data = self.star.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 6)
self.assertIn('pylinac version', data)
self.assertEqual(data['Starshot circle radius (mm)'], self.star.wobble.radius_mm)

9 changes: 9 additions & 0 deletions tests_basic/test_vmat.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ def test_publish_pdf(self):
instance.analyze()
save_file(instance.publish_pdf)

def test_results_data(self):
instance = self.klass.from_demo_images()
instance.analyze()
data = instance.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 7)
self.assertIn('pylinac version', data)
self.assertEqual(data['VMAT abs mean deviation (%)'], instance.avg_abs_r_deviation)


class TestDRGSLoading(TestLoadingBase, TestCase):
demo_name = 'drgs.zip'
Expand Down
8 changes: 6 additions & 2 deletions tests_basic/test_winstonlutz.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ def test_bb_shift_instructions(self):
self.assertTrue("RIGHT" in move)
self.assertTrue("VRT" in move)

def test_results_data(self):
self.assertTrue(bool(self.wl.results_data())) # empty dictionaries evaluate to false
def test_results_data(self):
data = self.wl.results_data()
self.assertIsInstance(data, dict)
self.assertEqual(len(data), 20)
self.assertIn('pylinac version', data)
self.assertEqual(data['WL Collimator 2D iso size (mm)'], self.wl.collimator_iso_size)


class TestResultsData(TestCase):
Expand Down

0 comments on commit cca3dc8

Please sign in to comment.