diff --git a/pylinac/core/profile.py b/pylinac/core/profile.py index 4612762b..947431a5 100644 --- a/pylinac/core/profile.py +++ b/pylinac/core/profile.py @@ -173,10 +173,17 @@ def dpmm(self, value): if value > 0: self._dpmm = value - @property - def profile_center(self): - """Returns the center index of the profile. Added by ACC 3/12/2020""" - return (self.values.shape[0] - 1)/2 + def profile_center(self) -> Tuple[NumberLike, NumberLike]: + """Returns the center index and value of the profile. If the profile has an even number of values the centre + lies between the two centre indices and the centre value is the average of the two centre values else the + centre index and value are returned. Added by ACC 3/12/2020""" + plen = self.values.shape[0] + if plen % 2 == 0: # plen is even and central detectors straddle CAX + cax = (self.values[int(plen / 2)] + self.values[int(plen / 2) - 1]) / 2.0 + else: # plen is odd and we have a central detector + cax = self.values[int((plen - 1) / 2)] + plen = (plen - 1)/2.0 + return plen, cax @property @lru_cache() diff --git a/pylinac/fieldparams.py b/pylinac/fieldparams.py index ecae4db6..ba0e36b0 100644 --- a/pylinac/fieldparams.py +++ b/pylinac/fieldparams.py @@ -27,13 +27,13 @@ def left_edge_50(profile: SingleProfile, *args) -> float: """Return the position of the 50% of max dose value on the left of the profile""" - left_edge = abs(profile.distance_to_dose(50, norm, interpolate)[0] - profile.profile_center)/profile.dpmm + left_edge = abs(profile.distance_to_dose(50, norm, interpolate)[0] - profile.profile_center()[0])/profile.dpmm return left_edge def right_edge_50(profile: SingleProfile, *args): """Return the position of the 50% of max dose value on the right of the profile""" - right_edge = abs(profile.distance_to_dose(50, norm, interpolate)[1] - profile.profile_center)/profile.dpmm + right_edge = abs(profile.distance_to_dose(50, norm, interpolate)[1] - profile.profile_center()[0])/profile.dpmm return right_edge @@ -51,7 +51,7 @@ def field_size_edge_50(profile: SingleProfile, *args): def field_center_fwhm(profile: SingleProfile, *args): """Field center as given by the center of the profile FWHM. Not affected by the normalisation mode. Included for testing purposes""" - field_center = (profile.fwxm_center(50, interpolate)[0] - profile.profile_center)/profile.dpmm + field_center = (profile.fwxm_center(50, interpolate)[0] - profile.profile_center()[0])/profile.dpmm return field_center @@ -100,7 +100,10 @@ def symmetry_point_difference(profile: SingleProfile, ifa: float=0.8): """Calculation of symmetry by way of point difference equidistant from the CAX. Field calculation is automatically centred.""" values = profile.field_values(field_width=ifa) - _, cax_val = profile.fwxm_center() + if norm in ['max', 'max grounded']: + _, cax_val = profile.fwxm_center() + else: + _, cax_val = profile.profile_center() sym_array = [] for lt_pt, rt_pt in zip(values, values[::-1]): val = 100 * abs(lt_pt - rt_pt) / cax_val @@ -121,6 +124,50 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): return symmetry +def symmetry_area(profile: SingleProfile, ifa: float = 0.8): + """Ratio of the area under the left and right profile segments. Field is automatically centered.""" + values = profile.field_values(field_width=ifa) + plen = len(values) + cax_idx = round((plen - 1)/2) + if plen % 2 == 0: # even number of values, CAX is straddled by centre values. + area_left = np.sum(values[:cax_idx]) + area_right = np.sum(values[cax_idx:]) + else: # include centre value on CAX + area_left = np.sum(values[:cax_idx + 1]) + area_right = np.sum(values[cax_idx:]) + symmetry = 100*abs(area_left - area_right)/(area_left + area_right) + return symmetry + + +def deviation_diff(profile: SingleProfile, ifa: float = 0.8): + """Maximum deviation""" + if norm in ['max', 'max grounded']: + _, cax_val = profile.fwxm_center() + else: + _, cax_val = profile.profile_center() + try: + dmax = profile.field_calculation(field_width=ifa, calculation='max') + dmin = profile.field_calculation(field_width=ifa, calculation='min') + except ValueError: + raise ValueError("An error was encountered in the deviation calculation. The image is likely inverted. Try inverting the image before analysis with .image.invert().") + deviation = 100*(dmax - dmin)/cax_val + return deviation + + +def deviation_max(profile: SingleProfile, ifa: float = 0.8): + """Maximum deviation""" + if norm in ['max', 'max grounded']: + _, cax_val = profile.fwxm_center() + else: + _, cax_val = profile.profile_center() + try: + dmax = profile.field_calculation(field_width=ifa, calculation='max') + except ValueError: + raise ValueError("An error was encountered in the deviation calculation. The image is likely inverted. Try inverting the image before analysis with .image.invert().") + deviation = 100*dmax/cax_val + return deviation + + # ---------------------------------------------------------------------------------------------------------------------- # Predefined Protocols - Do not change these. Instead copy a protocol, give it a new name, put it after these protocols # and add the protocol name to the dictionary PROTOCOLS. @@ -138,7 +185,10 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): 'Flatness diff': flatness_dose_difference, 'Flatness ratio': flatness_dose_ratio, 'Symmetry diff': symmetry_point_difference, - 'Symmetry ratio': symmetry_pdq_iec + 'Symmetry ratio': symmetry_pdq_iec, + 'Symmetry area': symmetry_area, + 'Deviation max': deviation_max, + 'Deviation diff': deviation_diff } VARIAN = { @@ -172,6 +222,7 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): 'Penumbra 80-20% right': penumbra_right_80_20, 'Flatness': flatness_dose_ratio, 'Symmetry': symmetry_pdq_iec, + 'Deviation diff': deviation_diff } SIEMENS = { @@ -181,7 +232,9 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): 'Field center': field_center_fwhm, 'Penumbra 80-20% left': penumbra_left_80_20, 'Penumbra 80-20% right': penumbra_right_80_20, - 'Flatness': flatness_dose_difference + 'Flatness': flatness_dose_difference, + 'Symmetry': symmetry_area, + 'Deviation max': deviation_max } VOM80 = { @@ -201,6 +254,30 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): 'Field center': field_center_fwhm, 'Penumbra 80-20% left': penumbra_left_80_20, 'Penumbra 80-20% right': penumbra_right_80_20, + 'Flatness': flatness_dose_ratio, + 'Symmetry': symmetry_pdq_iec, +} + +AFSSAPS_JORF = { + 'Left edge (50%)': left_edge_50, + 'Right edge (50%)': right_edge_50, + 'Field size': field_size_edge_50, + 'Field center': field_center_edge_50, + 'Penumbra 80-20% left': penumbra_left_80_20, + 'Penumbra 80-20% right': penumbra_right_80_20, + 'Flatness': flatness_dose_difference, + 'Symmetry': symmetry_pdq_iec, + 'Deviation max': deviation_max +} + +DIN = { + 'Left edge (50%)': left_edge_50, + 'Right edge (50%)': right_edge_50, + 'Field size': field_size_50, + 'Field center': field_center_fwhm, + 'Penumbra 80-20% left': penumbra_left_80_20, + 'Penumbra 80-20% right': penumbra_right_80_20, + 'Flatness': flatness_dose_ratio, 'Symmetry': symmetry_pdq_iec, } @@ -211,11 +288,14 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8): PROTOCOLS = { 'all': ALL, + 'default': VARIAN, 'varian': VARIAN, 'elekta': ELEKTA, 'siemens': SIEMENS, 'vom80': VOM80, - 'iec9076': IEC9076 + 'iec9076': IEC9076, + 'afssaps-jorf': AFSSAPS_JORF, + 'din': DIN } diff --git a/tests_basic/test_fieldparams.py b/tests_basic/test_fieldparams.py index ae100a64..7a83afd5 100644 --- a/tests_basic/test_fieldparams.py +++ b/tests_basic/test_fieldparams.py @@ -72,6 +72,17 @@ def test_symmetry_point_difference(self): def test_symmetry_pdq_iec(self): self.assertAlmostEqual(fp.symmetry_pdq_iec(self.profile, 0.8), 101.88, delta=self.delta) + def test_symmetry_area(self): + self.assertAlmostEqual(fp.symmetry_area(self.profile, 1.0), 0.44, delta=self.delta) + + def test_deviation_max(self): + fp.norm = 'cax' + self.assertAlmostEqual(fp.deviation_max(self.profile, 0.8), 104.80, delta=self.delta) + + def test_deviation_diff(self): + fp.norm = 'cax' + self.assertAlmostEqual(fp.deviation_diff(self.profile, 0.8), 4.80, delta=self.delta) + class FieldParamTests(TestCase): @@ -110,7 +121,7 @@ def test_analyze_sets_analyzed_flag(self): def test_protocols(self): fs = FieldParams.from_demo_image() analyze = partial(fs.analyze, protocol='varian') - for method in ('all', 'varian', 'elekta', 'siemens', 'vom80', 'iec9076'): + for method in ('all', 'default', 'varian', 'elekta', 'siemens', 'vom80', 'iec9076', 'din', 'afssaps-jorf'): analyze(protocol=method) # shouldn't raise def test_results(self):