diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1d652293d..fd8e8e36f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -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 -------- diff --git a/docs/source/planar_imaging.rst b/docs/source/planar_imaging.rst index 2581d2cdb..4a57bcd07 100644 --- a/docs/source/planar_imaging.rst +++ b/docs/source/planar_imaging.rst @@ -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 ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -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 @@ -1137,8 +1170,8 @@ do so fairly easily by overloading the current tooling: .. _planar_scaling: -Scaling -------- +Scaling measurement +------------------- .. versionadded:: 3.19 @@ -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 diff --git a/pylinac/core/geometry.py b/pylinac/core/geometry.py index 6677efa17..a7270ccbd 100644 --- a/pylinac/core/geometry.py +++ b/pylinac/core/geometry.py @@ -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: @@ -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: @@ -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")} diff --git a/pylinac/planar_imaging.py b/pylinac/planar_imaging.py index 42065c3fa..44816485d 100644 --- a/pylinac/planar_imaging.py +++ b/pylinac/planar_imaging.py @@ -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 @@ -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__( @@ -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. @@ -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 @@ -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() @@ -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, @@ -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, @@ -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, @@ -773,10 +821,12 @@ 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 @@ -784,7 +834,7 @@ 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 @@ -792,7 +842,7 @@ 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 @@ -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): diff --git a/tests_basic/test_planar_imaging.py b/tests_basic/test_planar_imaging.py index ec122708d..bba2367c1 100644 --- a/tests_basic/test_planar_imaging.py +++ b/tests_basic/test_planar_imaging.py @@ -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) @@ -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() @@ -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 diff --git a/tests_basic/utils.py b/tests_basic/utils.py index 5415b81ef..43d826ff0 100644 --- a/tests_basic/utils.py +++ b/tests_basic/utils.py @@ -12,7 +12,7 @@ from io import BytesIO, StringIO from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory -from typing import Callable, List, Sequence, Union +from typing import Callable, List, Sequence from urllib.request import urlopen from google.cloud import storage @@ -171,7 +171,7 @@ class CloudFileMixin: 1. Override ``file_path`` with a list that contains the subfolder(s) and file name. """ - file_name: Union[str, Sequence[str]] + file_name: str | Sequence[str] | None = None dir_path: Sequence[str] delete_file = True @@ -192,7 +192,7 @@ def get_filename(cls) -> str: @classmethod def tearDownClass(cls): - if cls.delete_file and DELETE_FILES: + if cls.delete_file and DELETE_FILES and cls.file_name: file = cls.get_filename() if osp.isfile(file): os.remove(file)