Skip to content

Commit

Permalink
Merged in feature/RAM-3648_fine_tune_planar (pull request #387)
Browse files Browse the repository at this point in the history
RAM-3648 fine-tuning parameters for planar analysis

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed May 31, 2024
2 parents c0b4dcc + 8bde057 commit 16688ee
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 18 deletions.
12 changes: 12 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ Image Generator
* When saving a simulated image to DICOM, the user can now choose whether to invert the image array.
This can help simulate older or newer EPID types.

Planar Imaging
^^^^^^^^^^^^^^

* Planar phantom analyses now have new parameter options for fine-tuning the automatic analysis. See :ref:`fine-tuning-planar`.

Core
^^^^

* Multiplying ``Point`` s together would not return a new point. It now performs both an in-place
and out-of-place multiplication. E.g. ``Point(1, 2) * 2`` will return a new point at (2, 4) and
also change the original point to (2, 4).

v 3.23.0
--------

Expand Down
42 changes: 40 additions & 2 deletions docs/source/planar_imaging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,35 @@ and methods, the plotting and PDF report functionality comes for free.
Usage tips, tweaks, & troubleshooting
-------------------------------------

.. _fine-tuning-planar:

Fine-tuning the ROI locations
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. versionadded:: 3.24

If after the automatic analysis you find that the ROIs are not quite where you want them, you can adjust the ROI locations
by setting any of the following parameters: ``x_adjustment``, ``y_adjustment``, ``angle_adjustment``, ``scaling_factor``,
or ``zoom_factor``. These parameters can be set in the ``analyze`` method.

.. code-block:: python
from pylinac import LeedsTOR
leeds = LeedsTOR(...)
leeds.analyze(
...,
x_adjustment=0.5,
y_adjustment=-0.3,
angle_adjustment=5,
scaling_factor=1.1,
roi_size_factor=0.9,
)
In contrast to the ``angle_override``, ``size_override``, and ``center_override`` parameters, the adjustments are applied
**after** the phantom localization. I.e. use adjustments if you need to fine-tune the automatic analysis; use overrides if the
detection is failing.

Set the SSD of your phantom
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand All @@ -1045,6 +1074,10 @@ distance via the ``ssd`` parameter.
Adjust an ROI on an existing phantom
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. note::

If you are trying to uniformly adjust all the ROIs, see :ref:`fine-tuning-planar`.

To adjust an ROI, override the relevant attribute or create a subclass. E.g. to move the 2nd ROI of the high-contrast ROI set of the QC-3 phantom:

.. code-block:: python
Expand Down Expand Up @@ -1137,8 +1170,8 @@ do so fairly easily by overloading the current tooling:
.. _planar_scaling:

Scaling
-------
Scaling measurement
-------------------

.. versionadded:: 3.19

Expand All @@ -1164,6 +1197,11 @@ E.g.:
Adjusting the scaling
^^^^^^^^^^^^^^^^^^^^^

.. note::

This can also be adjusted uniformly using the ``scaling_factor`` parameter in the ``analyze`` method.
The below method is recommended if your adjustments are not uniform in both directions. See :ref:`fine-tuning-planar`.

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
Expand Down
10 changes: 7 additions & 3 deletions pylinac/core/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ def dict(self) -> dict:
def __repr__(self) -> str:
return f"Point(x={self.x:3.2f}, y={self.y:3.2f}, z={self.z:3.2f})"

def __eq__(self, other) -> bool:
def __eq__(self, other: Point | Vector) -> bool:
# if all attrs equal, points considered equal
return all(
getattr(self, attr) == getattr(other, attr) for attr in self._attr_list
)

def __add__(self, other) -> Vector:
def __add__(self, other: Point | Vector) -> Vector:
p = Vector()
for attr in self._attr_list:
try:
Expand All @@ -187,12 +187,13 @@ def __sub__(self, other) -> Vector:
setattr(p, attr, diff)
return p

def __mul__(self, other: int | float) -> None:
def __mul__(self, other: int | float) -> Point:
for attr in self._attr_list:
try:
self.__dict__[attr] *= other
except TypeError:
pass
return self

def __truediv__(self, other: int | float) -> Point:
for attr in self._attr_list:
Expand Down Expand Up @@ -317,6 +318,9 @@ def as_scalar(self) -> float:
"""Return the scalar equivalent of the vector."""
return math.sqrt(self.x**2 + self.y**2 + self.z**2)

def as_point(self) -> Point:
return Point(self.x, self.y, self.z)

def dict(self) -> dict:
"""Convert to a dict. Shim until converting to dataclass"""
return {attr: getattr(self, attr) for attr in ("x", "y", "z")}
Expand Down
68 changes: 59 additions & 9 deletions pylinac/planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from skimage.measure._regionprops import RegionProperties

from . import Normalization
from .core import geometry, image, pdf
from .core import geometry, image, pdf, validators
from .core.contrast import Contrast
from .core.decorators import lru_cache
from .core.geometry import Circle, Point, Rectangle, Vector
Expand Down Expand Up @@ -155,6 +155,11 @@ class ImagePhantomBase(ResultsDataMixin[PlanarResult]):
phantom_bbox_size_mm2: float
roi_match_condition: Literal["max", "closest"] = "max"
mtf: MTF
x_adjustment: float
y_adjustment: float
angle_adjustment: float
roi_size_factor: float
scaling_factor: float
_ssd: float

def __init__(
Expand Down Expand Up @@ -285,6 +290,11 @@ def analyze(
ssd: float | Literal["auto"] = "auto",
low_contrast_method: str = Contrast.MICHELSON,
visibility_threshold: float = 100,
x_adjustment: float = 0,
y_adjustment: float = 0,
angle_adjustment: float = 0,
roi_size_factor: float = 1,
scaling_factor: float = 1,
) -> None:
"""Analyze the phantom using the provided thresholds and settings.
Expand Down Expand Up @@ -322,6 +332,29 @@ def analyze(
The equation to use for calculating low contrast.
visibility_threshold
The threshold for whether an ROI is "seen".
x_adjustment: float
A fine-tuning adjustment to the detected x-coordinate of the phantom center. This will move the
detected phantom position by this amount in the x-direction in mm. Positive values move the phantom to the right.
.. note::
This (along with the y-, scale-, and zoom-adjustment) is applied after the automatic detection in contrast to the center_override which is a **replacement** for
the automatic detection. The x, y, and angle adjustments cannot be used in conjunction with the angle, center, or size overrides.
y_adjustment: float
A fine-tuning adjustment to the detected y-coordinate of the phantom center. This will move the
detected phantom position by this amount in the y-direction in mm. Positive values move the phantom down.
angle_adjustment: float
A fine-tuning adjustment to the detected angle of the phantom. This will rotate the phantom by this amount in degrees.
Positive values rotate the phantom clockwise.
roi_size_factor: float
A fine-tuning adjustment to the ROI sizes of the phantom. This will scale the ROIs by this amount.
Positive values increase the ROI sizes. In contrast to the scaling adjustment, this
adjustment effectively makes the ROIs bigger or smaller, but does not adjust their position.
scaling_factor: float
A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline by this amount.
In contrast to the roi size adjustment, the scaling adjustment effectively moves the phantom and ROIs
closer or further from the phantom center. I.e. this zooms the outline and ROI positions, but not ROI size.
"""
self._angle_override = angle_override
self._center_override = center_override
Expand All @@ -330,6 +363,21 @@ def analyze(
self._low_contrast_threshold = low_contrast_threshold
self._low_contrast_method = low_contrast_method
self.visibility_threshold = visibility_threshold
# error checking
validators.is_positive(roi_size_factor)
validators.is_positive(scaling_factor)
# can't set overrides and adjustments
if any((angle_override, center_override, size_override)) and any(
(x_adjustment, y_adjustment, angle_adjustment, scaling_factor)
):
raise ValueError(
"Cannot set both overrides and adjustments. Use one or the other."
)
self.x_adjustment = x_adjustment
self.y_adjustment = y_adjustment
self.angle_adjustment = angle_adjustment
self.roi_size_factor = roi_size_factor
self.scaling_factor = scaling_factor
self._ssd = ssd
self._find_ssd()
self._check_inversion()
Expand Down Expand Up @@ -360,7 +408,7 @@ def _sample_low_contrast_rois(self) -> list[LowContrastDiskROI]:
roi = LowContrastDiskROI(
self.image,
self.phantom_angle + stng["angle"],
self.phantom_radius * stng["roi radius"],
self.phantom_radius * stng["roi radius"] * self.roi_size_factor,
self.phantom_radius * stng["distance from center"],
self.phantom_center,
self._low_contrast_threshold,
Expand All @@ -380,7 +428,7 @@ def _sample_low_contrast_background_rois(
roi = LowContrastDiskROI(
self.image,
self.phantom_angle + stng["angle"],
self.phantom_radius * stng["roi radius"],
self.phantom_radius * stng["roi radius"] * self.roi_size_factor,
self.phantom_radius * stng["distance from center"],
self.phantom_center,
self._low_contrast_threshold,
Expand All @@ -396,7 +444,7 @@ def _sample_high_contrast_rois(self) -> list[HighContrastDiskROI]:
roi = HighContrastDiskROI(
self.image,
self.phantom_angle + stng["angle"],
self.phantom_radius * stng["roi radius"],
self.phantom_radius * stng["roi radius"] * self.roi_size_factor,
self.phantom_radius * stng["distance from center"],
self.phantom_center,
self._high_contrast_threshold,
Expand Down Expand Up @@ -773,26 +821,28 @@ def publish_pdf(

@property
def phantom_center(self) -> Point:
# convert the adjustment from mm to pixels
adjustment = Point(x=self.x_adjustment, y=self.y_adjustment) * self.image.dpmm
return (
Point(self._center_override)
if self._center_override is not None
else self._phantom_center_calc()
else (self._phantom_center_calc() + adjustment).as_point()
)

@property
def phantom_radius(self) -> float:
return (
self._size_override
if self._size_override is not None
else self._phantom_radius_calc()
else self._phantom_radius_calc() * self.scaling_factor
)

@property
def phantom_angle(self) -> float:
return (
self._angle_override
if self._angle_override is not None
else self._phantom_angle_calc()
else self._phantom_angle_calc() + self.angle_adjustment
)

@property
Expand All @@ -801,10 +851,10 @@ def phantom_area(self) -> float:
area_px = self._create_phantom_outline_object()[0].area
return area_px / self.image.dpmm**2

def _phantom_center_calc(self):
def _phantom_center_calc(self) -> Point:
return bbox_center(self.phantom_ski_region)

def _phantom_angle_calc(self):
def _phantom_angle_calc(self) -> float:
pass

def _phantom_radius_calc(self):
Expand Down
86 changes: 85 additions & 1 deletion tests_basic/test_planar_imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ class PlanarPhantomMixin(CloudFileMixin):

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.instance = cls.create_instance()
cls.preprocess(cls.instance)
cls.instance.analyze(ssd=cls.ssd, invert=cls.invert)
Expand All @@ -223,7 +224,7 @@ def preprocess(cls, instance):
@classmethod
def tearDownClass(cls):
plt.close("all")
del cls.instance
super().tearDownClass()

def test_bad_inversion_recovers(self):
instance = self.create_instance()
Expand Down Expand Up @@ -421,6 +422,89 @@ def test_angle(self):
self.assertAlmostEqual(self.instance.phantom_angle, self.phantom_angle, delta=1)


class FineTuneAdjustments(TestCase):
def test_x_y_adjustments(self):
instance = LasVegas.from_demo_image()
# test before change
instance.analyze()
self.assertAlmostEqual(
instance.results_data().phantom_center_x_y[0], 636.5, delta=0.1
)
self.assertAlmostEqual(
instance.results_data().phantom_center_x_y[1], 637, delta=0.1
)
# test after change
instance.analyze(x_adjustment=20, y_adjustment=-15)
expected_shift_x = 20 * instance.image.dpmm
expected_shift_y = -15 * instance.image.dpmm
self.assertAlmostEqual(
instance.results_data().phantom_center_x_y[0],
636.5 + expected_shift_x,
delta=0.1,
)
self.assertAlmostEqual(
instance.results_data().phantom_center_x_y[1],
637 + expected_shift_y,
delta=0.1,
)

def test_angle_adjustment(self):
instance = LasVegas.from_demo_image()
# test before change
instance.analyze()
self.assertAlmostEqual(instance.phantom_angle, 0, delta=1)
# test after change
instance.analyze(angle_adjustment=10)
self.assertAlmostEqual(instance.phantom_angle, 10, delta=1)
# negative angle
instance.analyze(angle_adjustment=-10)
self.assertAlmostEqual(instance.phantom_angle, -10, delta=1)

def test_scaling_factor(self):
instance = LasVegas.from_demo_image()
# test before change
instance.analyze()
roi1 = instance.results_data().low_contrast_rois[0]["visibility"]
self.assertAlmostEqual(roi1, 512, delta=10)
# when the ROI is smaller the only thing that **should** change (assuming everything else is the same)
# is the visibility; the contrast changes for this exact test a bit, but the fact that
# the visibility is *almost* half gives us confidence that the scaling is working
instance.analyze(roi_size_factor=0.5)
scaled_roi = instance.results_data().low_contrast_rois[0]["visibility"]
self.assertAlmostEqual(scaled_roi, 275, delta=10)

def test_zoom_factor(self):
instance = LasVegas.from_demo_image()
# test before change
instance.analyze()
self.assertAlmostEqual(instance.phantom_radius, 1009, delta=3)
self.assertAlmostEqual(instance.results_data().phantom_area, 19633, delta=10)
# test after change
instance.analyze(scaling_factor=0.5)
self.assertAlmostEqual(instance.phantom_radius, 504, delta=2)
# will be a 1/4 the size (1/2 in each dimension)
self.assertAlmostEqual(
instance.results_data().phantom_area, 19633 / 2**2, delta=10
)

def test_negative_zoom_fails(self):
instance = LasVegas.from_demo_image()
with self.assertRaises(ValueError):
instance.analyze(scaling_factor=-1)

def test_negative_scaling_fails(self):
instance = LasVegas.from_demo_image()
with self.assertRaises(ValueError):
instance.analyze(roi_size_factor=-1)

def test_override_plus_adjustment_fails(self):
instance = LasVegas.from_demo_image()
with self.assertRaises(ValueError):
instance.analyze(size_override=2000, x_adjustment=1)
with self.assertRaises(ValueError):
instance.analyze(angle_override=22, y_adjustment=1)


class LasVegasDemo(LasVegasTestMixin, TestCase):
rois_seen = 12
piu = 98.4
Expand Down
Loading

0 comments on commit 16688ee

Please sign in to comment.