diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 27c2e944..d86fafaf 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -85,6 +85,19 @@ Image Generator * The Winston-Lutz image generator has a machine scale input. +v 3.21.1 +-------- + +VMAT +^^^^ + +* A bug in the VMAT analysis was causing apparent shifts in the ROI position. This would happen if the gaps between the + ROIs were below 50% of the maximum. The ROI position is now based on the center position of the open field rather than the center + of the DMLC image. This caused a shift in some of the ROI positions of the test images of a few pixels (2-7 pixels). This + also caused the ROI values to change by anywhere between 0 and 0.2% in our test suite. +* This same bug was causing identification issues of open vs DMLC images occassionally, usually for Halcyon datasets. The identification algorithm + has been adjusted to better detect these scenarios. + v 3.21.0 -------- diff --git a/pylinac/vmat.py b/pylinac/vmat.py index f436c7f7..a13e6fee 100644 --- a/pylinac/vmat.py +++ b/pylinac/vmat.py @@ -252,7 +252,22 @@ def _identify_images(self, image1: DicomImage, image2: DicomImage): profile1, profile2 = self._median_profiles(image1=image1, image2=image2) field_profile1 = profile1.field_values() field_profile2 = profile2.field_values() - if np.std(field_profile1) > np.std(field_profile2): + # first check if the profiles have a very different length + # if so, the longer one is the open field + # this leverages the shortcoming in FWXMProfile where the field might be very small because + # it "caught" on one of the first dips of the DMLC image + # catches most often with Halcyon images + if abs(len(field_profile1) - len(field_profile2)) > min( + len(field_profile1), len(field_profile2) + ): + if len(field_profile1) > len(field_profile2): + self.open_image = image1 + self.dmlc_image = image2 + else: + self.open_image = image2 + self.dmlc_image = image1 + # normal check of the STD compared; for flat-ish beams this works well. + elif np.std(field_profile1) > np.std(field_profile2): self.dmlc_image = image1 self.open_image = image2 else: @@ -308,8 +323,8 @@ def _generate_results_data(self) -> VMATResult: def _calculate_segment_centers(self) -> list[Point]: """Construct the center points of the segments based on the field center and known x-offsets.""" points = [] - dmlc_prof, _ = self._median_profiles(self.dmlc_image, self.open_image) - x_field_center = round(dmlc_prof.center_idx) + _, open_prof = self._median_profiles(self.dmlc_image, self.open_image) + x_field_center = round(open_prof.center_idx) for roi_data in self.roi_config.values(): x_offset_mm = roi_data["offset_mm"] y = self.open_image.center.y diff --git a/tests_basic/test_vmat.py b/tests_basic/test_vmat.py index be07c11f..4fce525a 100644 --- a/tests_basic/test_vmat.py +++ b/tests_basic/test_vmat.py @@ -1,6 +1,8 @@ import io import json +import tempfile from functools import partial +from pathlib import Path from typing import Iterable, Type, Union from unittest import TestCase @@ -9,6 +11,12 @@ from pylinac import DRGS, DRMLC from pylinac.core.geometry import Point +from pylinac.core.image_generator import ( + AS1200Image, + FilterFreeFieldLayer, + GaussianFilterLayer, + RandomNoiseLayer, +) from pylinac.vmat import VMATResult from tests_basic.utils import ( FromDemoImageTesterMixin, @@ -131,7 +139,8 @@ class VMATMixin: 0: {"r_dev": 0, "r_corr": 100}, 4: {"r_dev": 0, "r_corr": 100}, } - kwargs = {} + init_kwargs = {} + analyze_kwargs = {} avg_abs_r_deviation = 0 avg_r_deviation = 0 max_r_deviation = 0 @@ -151,10 +160,10 @@ def absolute_path(cls): def setUp(self): if self.is_zip: - self.vmat = self.klass.from_zip(self.absolute_path(), **self.kwargs) + self.vmat = self.klass.from_zip(self.absolute_path(), **self.init_kwargs) else: - self.vmat = self.klass(self.absolute_path(), **self.kwargs) - self.vmat.analyze() + self.vmat = self.klass(self.absolute_path(), **self.init_kwargs) + self.vmat.analyze(**self.analyze_kwargs) if self.print_debug: print(self.vmat.results()) print( @@ -218,7 +227,7 @@ class TestDRGSDemo(VMATMixin, TestCase): 0: {"r_dev": 0.965, "r_corr": 6.2, "stdev": 0.0008}, 4: {"r_dev": -0.459, "r_corr": 6, "stdev": 0.0007}, } - avg_abs_r_deviation = 0.66 + avg_abs_r_deviation = 0.74 max_r_deviation = 1.8 passes = False @@ -249,7 +258,7 @@ class TestDRMLCDemo(VMATMixin, TestCase): max_r_deviation = 0.89 def setUp(self): - self.vmat = DRMLC.from_demo_images(**self.kwargs) + self.vmat = DRMLC.from_demo_images(**self.init_kwargs) self.vmat.analyze() def test_demo(self): @@ -259,7 +268,7 @@ def test_demo(self): class TestDRMLCDemoRawPixels(TestDRMLCDemo): """Use raw DICOM pixel values, like doselab does.""" - kwargs = {"raw_pixels": True, "ground": False, "check_inversion": False} + init_kwargs = {"raw_pixels": True, "ground": False, "check_inversion": False} segment_values = { 0: {"r_dev": -0.55, "r_corr": 138.55}, 2: {"r_dev": 0.56, "r_corr": 140}, @@ -291,7 +300,7 @@ class TestDRGS105(VMATMixin, TestCase): filepaths = ("DRGSopen-105-example.dcm", "DRGSdmlc-105-example.dcm") klass = DRGS - segment_positions = {0: Point(371, 384), 2: Point(478, 384)} + segment_positions = {0: Point(378, 384), 2: Point(485, 384)} segment_values = { 0: { "r_dev": 1.385, @@ -314,7 +323,7 @@ class TestDRMLC2(VMATMixin, TestCase): 2: {"r_dev": -1.1, "r_corr": 6}, } avg_abs_r_deviation = 1.4 - max_r_deviation = 1.98 + max_r_deviation = 2.11 passes = False @@ -373,15 +382,25 @@ class TestHalcyonDRGS(VMATMixin, TestCase): klass = DRGS filepaths = ("HalcyonDRGS.zip",) + analyze_kwargs = { + "roi_config": { + "ROI 1": {"offset_mm": -120}, + "ROI 2": {"offset_mm": -80}, + "ROI 3": {"offset_mm": -40}, + "ROI 4": {"offset_mm": 0}, + "ROI 5": {"offset_mm": 40}, + "ROI 6": {"offset_mm": 80}, + "ROI 7": {"offset_mm": 120}, + } + } is_zip = True - segment_positions = {0: Point(364, 640), 2: Point(547, 640)} + segment_positions = {0: Point(89, 640), 2: Point(456, 640)} segment_values = { - 0: {"r_dev": 41.4, "r_corr": 1689.2}, - 2: {"r_dev": 28, "r_corr": 1529.5}, + 0: {"r_dev": 0.583, "r_corr": 13.62}, + 2: {"r_dev": -0.30, "r_corr": 13.5}, } - avg_abs_r_deviation = 32.64 - max_r_deviation = 41.4 - passes = False + avg_abs_r_deviation = 0.266 + max_r_deviation = 0.58 class TestHalcyonDRMLC(VMATMixin, TestCase): @@ -390,11 +409,99 @@ class TestHalcyonDRMLC(VMATMixin, TestCase): klass = DRMLC filepaths = ("HalcyonDRMLC.zip",) is_zip = True - segment_positions = {0: Point(433, 640), 2: Point(708, 640)} + analyze_kwargs = { + "roi_config": { + "ROI 1": {"offset_mm": -115}, + "ROI 2": {"offset_mm": -57.5}, + "ROI 3": {"offset_mm": 0}, + "ROI 4": {"offset_mm": 57.5}, + "ROI 5": {"offset_mm": 115}, + } + } + segment_positions = {0: Point(112, 640), 2: Point(639, 640)} + segment_values = { + 0: {"r_dev": -0.34, "r_corr": 2.8}, + 2: {"r_dev": 1.15, "r_corr": 2.85}, + } + avg_abs_r_deviation = 0.66 + max_r_deviation = 1.15 + passes = True + + +class TestHalcyonDRGS2(VMATMixin, TestCase): + """A Hal image w/ deep gaps between the ROIs. Causes a shift in the ROIs from RAM-3483""" + + klass = DRGS + filepaths = ("DRGS_Halcyon2.zip",) + is_zip = True + segment_positions = {0: Point(364, 640), 2: Point(543, 640)} + segment_values = { + 0: {"r_dev": 1.17, "r_corr": 13.73}, + 2: {"r_dev": -0.206, "r_corr": 13.56}, + } + avg_abs_r_deviation = 0.44 + max_r_deviation = 0.803 + passes = True + + +class TestHalcyonDRGS3(VMATMixin, TestCase): + """A TB image w/ deep gaps between the ROIs. Causes a shift in the ROIs from RAM-3483""" + + klass = DRGS + filepaths = ("DRGS_example_PM.zip",) + is_zip = True + segment_positions = {0: Point(280, 384), 2: Point(433, 384)} segment_values = { - 0: {"r_dev": 1.17, "r_corr": 3602}, - 2: {"r_dev": -0.206, "r_corr": 3552.8}, + 0: {"r_dev": -0.37, "r_corr": 13.73}, + 2: {"r_dev": -0.206, "r_corr": 13.56}, } - avg_abs_r_deviation = 0.585 - max_r_deviation = 1.17 + avg_abs_r_deviation = 0.89 + max_r_deviation = 1.87 + passes = False + + +class TestContrivedWideGapTest(VMATMixin, TestCase): + """A contrived test with a wide gap between the segments.""" + + klass = DRMLC + is_zip = False + segment_positions = {0: Point(506, 640), 2: Point(685, 640)} + segment_values = { + 0: {"r_dev": 0, "r_corr": 100}, + 2: {"r_dev": 0, "r_corr": 100}, + } + avg_abs_r_deviation = 0 + max_r_deviation = 0.0 passes = True + + def create_synthetic_images(self): + tmp_dir = Path(tempfile.gettempdir()) + as1200_open = AS1200Image(1000) + as1200_open.add_layer(FilterFreeFieldLayer(field_size_mm=(110, 110))) + as1200_open.add_layer(GaussianFilterLayer()) + open_path = tmp_dir / "contrived_wide_gap_open.dcm" + as1200_open.generate_dicom(open_path) + # generate the DMLC image + as1200_dmlc = AS1200Image(1000) + as1200_dmlc.add_layer( + FilterFreeFieldLayer(field_size_mm=(150, 20), cax_offset_mm=(0, 45)) + ) + as1200_dmlc.add_layer( + FilterFreeFieldLayer(field_size_mm=(150, 20), cax_offset_mm=(0, 15)) + ) + as1200_dmlc.add_layer( + FilterFreeFieldLayer(field_size_mm=(150, 20), cax_offset_mm=(0, -15)) + ) + as1200_dmlc.add_layer( + FilterFreeFieldLayer(field_size_mm=(150, 20), cax_offset_mm=(0, -45)) + ) + as1200_dmlc.add_layer(GaussianFilterLayer()) + as1200_dmlc.add_layer(RandomNoiseLayer(sigma=0.005)) + dmlc_path = tmp_dir / "contrived_wide_gap_dmlc.dcm" + as1200_dmlc.generate_dicom(dmlc_path) + return open_path, dmlc_path + + def setUp(self): + open_path, dmlc_path = self.create_synthetic_images() + self.vmat = self.klass(image_paths=(open_path, dmlc_path), **self.init_kwargs) + self.vmat.analyze(**self.analyze_kwargs)