Skip to content

Commit

Permalink
basic FF params
Browse files Browse the repository at this point in the history
  • Loading branch information
alanphys committed Jan 27, 2021
1 parent e8876e6 commit e2e8a75
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 12 deletions.
15 changes: 11 additions & 4 deletions pylinac/core/profile.py
Expand Up @@ -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()
Expand Down
94 changes: 87 additions & 7 deletions pylinac/fieldparams.py
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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 <instance>.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 <instance>.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.
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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,
}

Expand All @@ -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
}


Expand Down
13 changes: 12 additions & 1 deletion tests_basic/test_fieldparams.py
Expand Up @@ -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):

Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit e2e8a75

Please sign in to comment.