Skip to content

Commit

Permalink
Merged in feature/RAM-3146_planar_scaling_area (pull request #324)
Browse files Browse the repository at this point in the history
RAM-3146 Add planar scaling

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Jan 4, 2024
2 parents 7c85274 + 26e3c5d commit 0fb802e
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 3 deletions.
7 changes: 7 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^
Expand All @@ -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
--------

Expand Down
50 changes: 50 additions & 0 deletions docs/source/planar_imaging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------

Expand Down
10 changes: 10 additions & 0 deletions pylinac/core/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
10 changes: 10 additions & 0 deletions pylinac/planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 #:

Expand Down Expand Up @@ -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 += [
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions tests_basic/core/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
12 changes: 9 additions & 3 deletions tests_basic/test_planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -98,15 +98,15 @@ 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):
phan = LasVegas.from_demo_image()
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()
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 0fb802e

Please sign in to comment.