From abf48620bf67223b4f7d196fb1a3a2c838f210e5 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Mon, 18 Nov 2024 13:56:17 -0600 Subject: [PATCH 1/2] add origin slice parameter to CT-like analysis --- docs/source/acr.rst | 2 ++ docs/source/cbct.rst | 2 ++ docs/source/changelog.rst | 10 ++++++++++ docs/source/cheese.rst | 2 ++ docs/source/quart.rst | 2 ++ pylinac/acr.py | 6 +++++- pylinac/cheese.py | 6 +++++- pylinac/ct.py | 24 ++++++++++++++++++------ pylinac/quart.py | 6 +++++- tests_basic/test_acr.py | 5 +++++ tests_basic/test_cbct.py | 13 +++++++++++++ tests_basic/test_cheese.py | 5 +++++ tests_basic/test_dlg.py | 4 ++-- 13 files changed, 76 insertions(+), 11 deletions(-) diff --git a/docs/source/acr.rst b/docs/source/acr.rst index 25c538da..58791501 100644 --- a/docs/source/acr.rst +++ b/docs/source/acr.rst @@ -241,6 +241,8 @@ CT Analysis Parameters * **Scaling factor**: A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + * **Origin slice**: The slice number that corresponds to the HU linearity slice. + This is a fallback mechanism in case the automatic detection fails. Interpreting CT Results ----------------------- diff --git a/docs/source/cbct.rst b/docs/source/cbct.rst index e53948e8..a8a63f3b 100644 --- a/docs/source/cbct.rst +++ b/docs/source/cbct.rst @@ -520,6 +520,8 @@ This applies to the 503, 504, 600, and 604. Model-specific parameters are called * **Scaling factor**: A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + * **Origin slice**: The slice number that corresponds to the HU linearity slice. + This is a fallback mechanism in case the automatic detection fails. .. _cbct-algorithm: diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index ad3adad0..15fdaefa 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,16 @@ Legend * :bdg-primary:`Refactor` denotes a code refactor; usually this means an efficiency boost or code cleanup. * :bdg-danger:`Change` denotes a change that may break existing code. +v 3.30.0 +-------- + +CT-like +^^^^^^^ + +* :bdg-success:`Feature` CT-like algorithms have a new parameter for the ``analyze`` method: ``origin_slice``. + This parameter lets the user set the z-position of the phantom. This is a fallback method to let the user set the + slice of (usually) the HU linearity module. This is useful if the automatic detection of the origin slice fails. + v 3.29.0 -------- diff --git a/docs/source/cheese.rst b/docs/source/cheese.rst index 3818d776..1a0e88db 100644 --- a/docs/source/cheese.rst +++ b/docs/source/cheese.rst @@ -269,6 +269,8 @@ Analysis Parameters * **Scaling factor**: A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + * **Origin slice**: The slice number that corresponds to the slice to analyze. + This is a fallback mechanism in case the automatic detection fails. Algorithm --------- diff --git a/docs/source/quart.rst b/docs/source/quart.rst index 4b360d0a..1e9a8122 100644 --- a/docs/source/quart.rst +++ b/docs/source/quart.rst @@ -147,6 +147,8 @@ Analysis Parameters * **Scaling factor**: A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + * **Origin slice**: The slice number that corresponds to the HU linearity slice. + This is a fallback mechanism in case the automatic detection fails. Algorithm --------- diff --git a/pylinac/acr.py b/pylinac/acr.py index 8473c642..8439a5a2 100644 --- a/pylinac/acr.py +++ b/pylinac/acr.py @@ -309,6 +309,7 @@ def analyze( angle_adjustment: float = 0, roi_size_factor: float = 1, scaling_factor: float = 1, + origin_slice: int | None = None, ) -> None: """Analyze the ACR CT phantom @@ -331,13 +332,16 @@ def analyze( A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + origin_slice: int, None + The slice number of the HU linearity module. If None, will be automatically determined. This is a fallback + method in case the automatic determination fails. """ 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.localize() + self.localize(origin_slice=origin_slice) self.ct_calibration_module = self.ct_calibration_module( self, offset=0, clear_borders=self.clear_borders ) diff --git a/pylinac/cheese.py b/pylinac/cheese.py index b60d2696..6990f291 100644 --- a/pylinac/cheese.py +++ b/pylinac/cheese.py @@ -259,6 +259,7 @@ def analyze( angle_adjustment: float = 0, roi_size_factor: float = 1, scaling_factor: float = 1, + origin_slice: int | None = None, ) -> None: """Analyze the Tomo Cheese phantom. @@ -283,13 +284,16 @@ def analyze( A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + origin_slice : int, None + The slice number to analyze. If None, the slice will be automatically determined. This is a fallback + method in case the automatic slice detection fails. """ 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.localize() + self.localize(origin_slice=origin_slice) self.module = self.module_class(self, clear_borders=self.clear_borders) self.roi_config = roi_config diff --git a/pylinac/ct.py b/pylinac/ct.py index 91a6f0aa..42f3f4c3 100644 --- a/pylinac/ct.py +++ b/pylinac/ct.py @@ -2068,14 +2068,18 @@ def _results(self) -> None: mtfs[mtf] = mtfval print(f"MTFs: {mtfs}") - def localize(self) -> None: + def localize(self, origin_slice: int | None) -> None: """Find the slice number of the catphan's HU linearity module and roll angle""" self._phantom_center_func = self.find_phantom_axis() - self.origin_slice = self.find_origin_slice() + if origin_slice is not None: + self.origin_slice = origin_slice + else: + self.origin_slice = self.find_origin_slice() self.catphan_roll = self.find_phantom_roll() + self.angle_adjustment - self.origin_slice = self.refine_origin_slice( - initial_slice_num=self.origin_slice - ) + if origin_slice is None: + self.origin_slice = self.refine_origin_slice( + initial_slice_num=self.origin_slice + ) # now that we have the origin slice, ensure we have scanned all linked modules if not self._ensure_physical_scan_extent(): raise ValueError( @@ -2259,6 +2263,10 @@ def find_phantom_roll(self, func: Callable | None = None) -> float: sorted_bubbles = sorted( central_bubbles, key=lambda x: x.centroid[0] ) # top, bottom + if not sorted_bubbles: + raise ValueError( + "No air bubbles were found in the HU slice. The origin slice algorithm likely failed or the origin slice was passed and is incorrect." + ) y_dist = sorted_bubbles[1].centroid[0] - sorted_bubbles[0].centroid[0] x_dist = sorted_bubbles[1].centroid[1] - sorted_bubbles[0].centroid[1] phan_roll = np.arctan2(y_dist, x_dist) @@ -2430,6 +2438,7 @@ def analyze( angle_adjustment: float = 0, roi_size_factor: float = 1, scaling_factor: float = 1, + origin_slice: int | None = None, ): """Single-method full analysis of CBCT DICOM files. @@ -2491,13 +2500,16 @@ def analyze( A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + origin_slice : int, None + The slice number of the HU linearity module. If None, the slice will be determined automatically. This is + a fallback method if the automatic localization algorithm fails. """ 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.localize() + self.localize(origin_slice) ctp404, offset = self._get_module(CTP404CP504, raise_empty=True) self.ctp404 = ctp404( self, diff --git a/pylinac/quart.py b/pylinac/quart.py index 3b9c718a..a2b2af01 100644 --- a/pylinac/quart.py +++ b/pylinac/quart.py @@ -432,6 +432,7 @@ def analyze( angle_adjustment: float = 0, roi_size_factor: float = 1, scaling_factor: float = 1, + origin_slice: int | None = None, ): """Single-method full analysis of Quart DICOM files. @@ -470,13 +471,16 @@ def analyze( A fine-tuning adjustment to the detected magnification of the phantom. This will zoom the ROIs and phantom outline (if applicable) 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. + origin_slice : int, None + The slice number of the HU linearity slice. If None, will be automatically determined. This is a + fallback method in case the automatic method fails. """ 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.localize() + self.localize(origin_slice=origin_slice) self.hu_module = self.hu_module_class( self, offset=0, diff --git a/tests_basic/test_acr.py b/tests_basic/test_acr.py index 00224783..8c107a80 100644 --- a/tests_basic/test_acr.py +++ b/tests_basic/test_acr.py @@ -80,6 +80,11 @@ def test_lazy_is_same_as_default(self): lazy_ct.analyze() self.assertEqual(self.ct.results(), lazy_ct.results()) + def test_passing_origin_slice(self): + ct = ACRCT.from_zip(self.path) + ct.analyze(origin_slice=3) # automatic is 2 + self.assertEqual(ct.origin_slice, 3) + class TestPlottingSaving(TestCase): @classmethod diff --git a/tests_basic/test_cbct.py b/tests_basic/test_cbct.py index 302d4e39..27e5876c 100644 --- a/tests_basic/test_cbct.py +++ b/tests_basic/test_cbct.py @@ -224,6 +224,19 @@ def test_same_results_for_lazy_load(self): lazy_ct.analyze() self.assertEqual(ct.results(), lazy_ct.results()) + def test_passing_origin_slice_works(self): + ct = CatPhan504.from_demo_images() + ct.analyze(origin_slice=33) # automatic slice is 32 + self.assertEqual(ct.origin_slice, 33) + + def test_passing_origin_slice_doesnt_perform_refinement(self): + # the catphan604 has a refinement algorithm to "fine-tune" + # the origin slice; ensure that it doesn't run when the origin slice is passed + # You get what you set! + ct = CatPhan604.from_demo_images() + ct.analyze(origin_slice=46) # automatic slice is 45 + self.assertEqual(ct.origin_slice, 46) + class Test504Quaac(QuaacTestBase, TestCase): def quaac_instance(self): diff --git a/tests_basic/test_cheese.py b/tests_basic/test_cheese.py index 28ce626c..375a269e 100644 --- a/tests_basic/test_cheese.py +++ b/tests_basic/test_cheese.py @@ -74,6 +74,11 @@ class TestGeneral(TestCase): def test_demo(self): TomoCheese.run_demo() + def test_passing_origin_slice(self): + cheese = TomoCheese.from_demo_images() + cheese.analyze(origin_slice=13) + assert cheese.origin_slice == 10 + class TestAnalysis(TestCase): def test_cropping_before_analysis(self): diff --git a/tests_basic/test_dlg.py b/tests_basic/test_dlg.py index 5fc50473..374a3158 100644 --- a/tests_basic/test_dlg.py +++ b/tests_basic/test_dlg.py @@ -1,6 +1,6 @@ import unittest -from pylinac.dlg import DLG +from pylinac.dlg import SingleFieldDLG from pylinac.picketfence import MLC from tests_basic.utils import get_file_from_cloud_test_repo @@ -9,6 +9,6 @@ class TestDLG(unittest.TestCase): file_path = get_file_from_cloud_test_repo(["DLG_1.5_0.2.dcm"]) def test_measured_dlg(self): - dlg = DLG(self.file_path) + dlg = SingleFieldDLG(self.file_path) dlg.analyze(gaps=(-0.9, -1.1, -1.3, -1.5, -1.7, -1.9), mlc=MLC.MILLENNIUM) self.assertAlmostEqual(dlg.measured_dlg, 1.503, delta=0.001) From 2b464e53d3b74646270f0ef51a68bb7643f468ed Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 19 Nov 2024 14:37:25 -0600 Subject: [PATCH 2/2] fix tests --- tests_basic/test_cheese.py | 4 ++-- tests_basic/test_dlg.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests_basic/test_cheese.py b/tests_basic/test_cheese.py index 375a269e..aeff0693 100644 --- a/tests_basic/test_cheese.py +++ b/tests_basic/test_cheese.py @@ -76,8 +76,8 @@ def test_demo(self): def test_passing_origin_slice(self): cheese = TomoCheese.from_demo_images() - cheese.analyze(origin_slice=13) - assert cheese.origin_slice == 10 + cheese.analyze(origin_slice=26) # automatic is 24 + assert cheese.origin_slice == 26 class TestAnalysis(TestCase): diff --git a/tests_basic/test_dlg.py b/tests_basic/test_dlg.py index 374a3158..5fc50473 100644 --- a/tests_basic/test_dlg.py +++ b/tests_basic/test_dlg.py @@ -1,6 +1,6 @@ import unittest -from pylinac.dlg import SingleFieldDLG +from pylinac.dlg import DLG from pylinac.picketfence import MLC from tests_basic.utils import get_file_from_cloud_test_repo @@ -9,6 +9,6 @@ class TestDLG(unittest.TestCase): file_path = get_file_from_cloud_test_repo(["DLG_1.5_0.2.dcm"]) def test_measured_dlg(self): - dlg = SingleFieldDLG(self.file_path) + dlg = DLG(self.file_path) dlg.analyze(gaps=(-0.9, -1.1, -1.3, -1.5, -1.7, -1.9), mlc=MLC.MILLENNIUM) self.assertAlmostEqual(dlg.measured_dlg, 1.503, delta=0.001)