diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1a002df82..0db10c5e1 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,7 @@ Core * The efficient DICOM stack introduced in the last version did not allow for writing images back to the stack (e.g. when manipulating the image). Images can now be written back to efficient stacks. +* ``Rectangle`` and ``Circle`` classes have a new property: ``area``. This will return the area of the shape. Profiles ^^^^^^^^ @@ -25,6 +26,12 @@ Profiles This method will resample the profile to the x-values of another profile. This is useful for comparing profiles point-by-point, such as for a 1D gamma evaluation. +Planar +^^^^^^ + +* Planar phantom analyses now have a ``phantom_area`` property available. This is also available in the ``results_data`` + method. This area is useful to test scaling of the image. See :ref:`planar_scaling` for more. + v 3.18.0 -------- diff --git a/docs/source/planar_imaging.rst b/docs/source/planar_imaging.rst index 6c6902046..2581d2cdb 100644 --- a/docs/source/planar_imaging.rst +++ b/docs/source/planar_imaging.rst @@ -1135,6 +1135,56 @@ do so fairly easily by overloading the current tooling: # proceed as normal myleeds = LeedsTOR(...) +.. _planar_scaling: + +Scaling +------- + +.. versionadded:: 3.19 + +Pylinac can produce an area calculation of the phantom. This can be used +as a way to test the scaling of the imager per TG-142. The scaling is +based on the blue rectangle/circle that is shown in the plots. + +E.g.: + +.. code-block:: python + + leeds = pylinac.LeedsTOR(...) + leeds.analyze(...) + results = leeds.results_data() + print(results.phantom_area) # in mm^2 + +.. warning:: + + The produced scaling value is based on the blue rectangle/circle. + In many cases it does not equal the exact size of the phantom. + It is recommended to be used as a constancy check. + +Adjusting the scaling +^^^^^^^^^^^^^^^^^^^^^ + +If you are dead-set on having the scaling value be the exact size of the phantom, +or you simply have a different interpretation of what the scaling should be you +can override the scaling calculation to a degree. The scaling is calculated +using the ``phantom_outline_object`` attribute. This attribute is a dictionary +and defines the size of the rectangle/circle that is shown in the plots. Changing +these values will both change the plot and the area/scaling value. + +.. code-block:: python + + import pylinac + + + class NewSNCkV(pylinac.SNCkV): + phantom_outline_object = { + "Rectangle": {"width ratio": 8.4, "height ratio": 7.2} # change these + } + + + class NewLeeds(pylinac.LeedsTOR): + phantom_outline_object = {"Circle": {"radius ratio": 1.3}} # change this + Wrong phantom angle ------------------- diff --git a/pylinac/core/geometry.py b/pylinac/core/geometry.py index d9a4b0cb5..f30f0cdee 100644 --- a/pylinac/core/geometry.py +++ b/pylinac/core/geometry.py @@ -181,6 +181,11 @@ def __init__(self, center_point: Point | Iterable = (0, 0), radius: float = 0): self.center = center_point self.radius = radius + @property + def area(self) -> float: + """The area of the circle.""" + return math.pi * self.radius**2 + @property def diameter(self) -> float: """Get the diameter of the circle.""" @@ -459,6 +464,11 @@ def __init__( self._as_int = as_int self.center = Point(center, as_int=as_int) + @property + def area(self) -> float: + """The area of the rectangle.""" + return self.width * self.height + @property def br_corner(self) -> Point: """The location of the bottom right corner.""" diff --git a/pylinac/planar_imaging.py b/pylinac/planar_imaging.py index 280b2a023..be47409c1 100644 --- a/pylinac/planar_imaging.py +++ b/pylinac/planar_imaging.py @@ -66,6 +66,7 @@ class PlanarResult(ResultBase): num_contrast_rois_seen: int #: phantom_center_x_y: tuple[float, float] #: low_contrast_rois: list[dict] #: + phantom_area: float #: The area of the phantom in pixels^2 mtf_lp_mm: tuple[float, float, float] | None = None #: percent_integral_uniformity: float | None = None #: @@ -670,6 +671,7 @@ def results(self, as_list: bool = False) -> str | list[str]: f"Median Contrast: {np.median([roi.contrast for roi in self.low_contrast_rois]):2.2f}", f"Median CNR: {np.median([roi.contrast_to_noise for roi in self.low_contrast_rois]):2.1f}", f'# Low contrast ROIs "seen": {sum(roi.passed_visibility for roi in self.low_contrast_rois):2.0f} of {len(self.low_contrast_rois)}', + f"Area: {self.phantom_area:2.2f} mm^2", ] if self.high_contrast_rois: text += [ @@ -694,6 +696,7 @@ def results_data(self, as_dict=False) -> PlanarResult | dict: phantom_center_x_y=(self.phantom_center.x, self.phantom_center.y), low_contrast_rois=[roi.as_dict() for roi in self.low_contrast_rois], percent_integral_uniformity=self.percent_integral_uniformity(), + phantom_area=self.phantom_area, ) if self.mtf is not None: @@ -798,6 +801,12 @@ def phantom_angle(self) -> float: else self._phantom_angle_calc() ) + @property + def phantom_area(self) -> float: + """The area of the detected ROI in mm^2""" + area_px = self._create_phantom_outline_object()[0].area + return area_px / self.image.dpmm**2 + def _phantom_center_calc(self): return bbox_center(self.phantom_ski_region) @@ -1425,6 +1434,7 @@ def results_data(self, as_dict: bool = False) -> PlanarResult | dict: phantom_center_x_y=(self.phantom_center.x, self.phantom_center.y), low_contrast_rois=[r.as_dict() for r in self.low_contrast_rois], percent_integral_uniformity=self.percent_integral_uniformity(), + phantom_area=self.phantom_area, ) if as_dict: return dataclasses.asdict(data) diff --git a/tests_basic/core/test_geometry.py b/tests_basic/core/test_geometry.py index 8086953bc..6a68cc3bb 100644 --- a/tests_basic/core/test_geometry.py +++ b/tests_basic/core/test_geometry.py @@ -87,6 +87,10 @@ def test_inputs(self): self.assertRaises(TypeError, Circle, 20) + def test_area(self): + c = Circle(radius=10) + self.assertAlmostEqual(c.area, math.pi * 10**2) + class TestLine(unittest.TestCase): point_1 = Point(1, 1) @@ -172,3 +176,7 @@ def test_corners(self): point_equality_validation(rect.br_corner, self.br_corner) point_equality_validation(rect.tr_corner, self.tr_corner) point_equality_validation(rect.tl_corner, self.tl_corner) + + def test_area(self): + r = Rectangle(width=10, height=10, center=(0, 0)) + self.assertAlmostEqual(r.area, 100) diff --git a/tests_basic/test_planar_imaging.py b/tests_basic/test_planar_imaging.py index 19633d2e6..e123562c5 100644 --- a/tests_basic/test_planar_imaging.py +++ b/tests_basic/test_planar_imaging.py @@ -84,7 +84,7 @@ def test_results(self): data_list = phan.results(as_list=True) self.assertIsInstance(data_list, list) - self.assertEqual(len(data_list), 8) + self.assertEqual(len(data_list), 9) def test_results_data(self): phan = LeedsTOR.from_demo_image() @@ -98,7 +98,7 @@ def test_results_data(self): data_dict = phan.results_data(as_dict=True) self.assertIsInstance(data_dict, dict) - self.assertEqual(len(data_dict), 10) + self.assertEqual(len(data_dict), 11) self.assertIn("pylinac_version", data_dict) def test_results_data_no_mtf(self): @@ -106,7 +106,7 @@ def test_results_data_no_mtf(self): phan.analyze() data_dict = phan.results_data(as_dict=True) - self.assertEqual(len(data_dict), 10) + self.assertEqual(len(data_dict), 11) def test_set_figure_size(self): phan = LeedsTOR.from_demo_image() @@ -176,6 +176,12 @@ def test_ssd_values(self): phan = LeedsTOR.from_demo_image() phan.analyze(ssd=1500) # really at 1000 + def test_scaling_area(self): + """Test various scaling area values""" + phan = LeedsTOR.from_demo_image() + phan.analyze() + self.assertAlmostEqual(phan.results_data().phantom_area, 17760.9, delta=0.3) + class PlanarPhantomMixin(CloudFileMixin): klass: Callable