diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index edfc4c18..1d652293 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -12,6 +12,12 @@ CBCT phantom to minimize edge artifacts and ensure the entire phantom is captured, we have reduced the required clearance of the phantom to the edge by approximately half. +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. + v 3.23.0 -------- diff --git a/pylinac/core/image_generator/simulators.py b/pylinac/core/image_generator/simulators.py index 7edc7e3a..dedc106c 100644 --- a/pylinac/core/image_generator/simulators.py +++ b/pylinac/core/image_generator/simulators.py @@ -9,6 +9,14 @@ from .layers import Layer +def generate_file_metadata() -> Dataset: + file_meta = FileMetaDataset() + file_meta.TransferSyntaxUID = UID( + "1.2.840.10008.1.2" + ) # default DICOM transfer syntax + return file_meta + + class Simulator(ABC): """Abstract class for an image simulator""" @@ -32,25 +40,20 @@ def add_layer(self, layer: Layer) -> None: """Add a layer to the image""" self.image = layer.apply(self.image, self.pixel_size, self.mag_factor) - def as_dicom( - self, - gantry_angle: float = 0.0, - coll_angle: float = 0.0, - table_angle: float = 0.0, - ) -> Dataset: + def as_dicom(self, *args, **kwargs) -> Dataset: """Create and return a pydicom Dataset. I.e. create a pseudo-DICOM image.""" raise NotImplementedError( "This method has not been implemented for this simulator. Overload the method of your simulator." ) - def generate_dicom( - self, - file_out_name: str, - gantry_angle: float = 0.0, - coll_angle: float = 0.0, - table_angle: float = 0.0, - ) -> None: - ds = self.as_dicom(gantry_angle, coll_angle, table_angle) + def generate_dicom(self, file_out_name: str, *args, **kwargs) -> None: + """Save the simulated image to a DICOM file. + + See Also + -------- + as_dicom + """ + ds = self.as_dicom(*args, **kwargs) ds.save_as(file_out_name, write_like_original=False) @@ -65,11 +68,13 @@ def as_dicom( gantry_angle: float = 0.0, coll_angle: float = 0.0, table_angle: float = 0.0, + invert_array: bool = True, ) -> Dataset: # make image look like an EPID with flipped data (dose->low) - flipped_image = -self.image + self.image.max() + self.image.min() - - file_meta = FileMetaDataset() + if invert_array: + array = -self.image + self.image.max() + self.image.min() + else: + array = self.image # Main data elements ds = Dataset() ds.ImageType = ["DERIVED", "SECONDARY", "PORTAL"] @@ -94,10 +99,7 @@ def as_dicom( ds.PatientID = "zzzBAC_Lutz" ds.PatientBirthDate = "" ds.SoftwareVersions = "2.41.01J0" - ds.StudyInstanceUID = "1.2.840.113854.141883099300381770008774160544352783139" - ds.SeriesInstanceUID = ( - "1.2.840.113854.141883099300381770008774160544352783139.1" - ) + ds.StudyInstanceUID = generate_uid() ds.StudyID = "348469" ds.SeriesNumber = "4597199" ds.InstanceNumber = "0" @@ -122,9 +124,9 @@ def as_dicom( ds.GantryAngle = str(gantry_angle) ds.BeamLimitingDeviceAngle = str(coll_angle) ds.PatientSupportAngle = str(table_angle) - ds.PixelData = flipped_image # XXX Array of 393216 bytes excluded + ds.PixelData = array.tobytes() # XXX Array of 393216 bytes excluded - ds.file_meta = file_meta + ds.file_meta = generate_file_metadata() ds.is_implicit_VR = True ds.is_little_endian = True return ds @@ -141,12 +143,13 @@ def as_dicom( gantry_angle: float = 0.0, coll_angle: float = 0.0, table_angle: float = 0.0, + invert_array: bool = True, ) -> Dataset: # make image look like an EPID with flipped data (dose->low) - flipped_image = -self.image + self.image.max() + self.image.min() - - # File meta info data elements - file_meta = FileMetaDataset() + if invert_array: + array = -self.image + self.image.max() + self.image.min() + else: + array = self.image # Main data elements ds = Dataset() @@ -172,10 +175,7 @@ def as_dicom( ds.PatientID = "abc123" ds.PatientBirthDate = "" ds.SoftwareVersions = "2.41.01J0" - ds.StudyInstanceUID = "1.2.840.113854.323870129946883845737820671794195198978" - ds.SeriesInstanceUID = ( - "1.2.840.113854.323870129946883845737820671794195198978.1" - ) + ds.StudyInstanceUID = generate_uid() ds.StudyID = "348469" ds.SeriesNumber = "4290463" ds.InstanceNumber = "0" @@ -200,9 +200,9 @@ def as_dicom( ds.GantryAngle = str(gantry_angle) ds.BeamLimitingDeviceAngle = str(coll_angle) ds.PatientSupportAngle = str(table_angle) - ds.PixelData = flipped_image # XXX Array of 1572864 bytes excluded + ds.PixelData = array.tobytes() # XXX Array of 1572864 bytes excluded - ds.file_meta = file_meta + ds.file_meta = generate_file_metadata() ds.is_implicit_VR = True ds.is_little_endian = True return ds @@ -219,17 +219,12 @@ def as_dicom( gantry_angle: float = 0.0, coll_angle: float = 0.0, table_angle: float = 0.0, + invert_array: bool = True, ) -> Dataset: - file_meta = FileMetaDataset() - file_meta.FileMetaInformationGroupLength = 196 - file_meta.FileMetaInformationVersion = b"\x00\x01" - file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.481.1" - file_meta.MediaStorageSOPInstanceUID = ( - "1.2.246.352.64.1.5468686515961995030.4457606667843517571" - ) - file_meta.TransferSyntaxUID = "1.2.840.10008.1.2" - file_meta.ImplementationClassUID = "1.2.246.352.70.2.1.120.1" - file_meta.ImplementationVersionName = "MergeCOM3_410" + if invert_array: + array = -self.image + self.image.max() + self.image.min() + else: + array = self.image # Main data elements ds = Dataset() @@ -258,10 +253,7 @@ def as_dicom( ds.PatientBirthTime = "000000" ds.PatientSex = "" ds.SoftwareVersions = "2.5.13.2" - ds.StudyInstanceUID = "1.2.246.352.64.4.5644626269434644263.1905029945372990626" - ds.SeriesInstanceUID = ( - "1.2.246.352.64.2.5508761605912087323.11665958260371685307" - ) + ds.StudyInstanceUID = generate_uid() ds.StudyID = "fdd794f2-8520-4c4a-aecc-e4446c1730ff" ds.SeriesNumber = None ds.AcquisitionNumber = "739774555" @@ -282,9 +274,6 @@ def as_dicom( ds.PixelRepresentation = 0 ds.WindowCenter = "32767.0" ds.WindowWidth = "65535.0" - ds.RescaleIntercept = "0.0" - ds.RescaleSlope = "1.0" - ds.RescaleType = "US" ds.RTImageLabel = "MV_180" ds.RTImageDescription = "" ds.ReportedValuesOrigin = "ACTUAL" @@ -304,9 +293,9 @@ def as_dicom( ds.TableTopVerticalPosition = "-24.59382842824" ds.TableTopLongitudinalPosition = "200.813502948597" ds.TableTopLateralPosition = "3.00246706215532" - ds.PixelData = self.image # XXX Array of 3276800 bytes excluded + ds.PixelData = array.tobytes() # XXX Array of 3276800 bytes excluded - ds.file_meta = file_meta + ds.file_meta = generate_file_metadata() ds.is_implicit_VR = True ds.is_little_endian = True return ds diff --git a/tests_basic/core/test_generator.py b/tests_basic/core/test_image_generator.py similarity index 97% rename from tests_basic/core/test_generator.py rename to tests_basic/core/test_image_generator.py index dbe75635..c0f9c6e3 100644 --- a/tests_basic/core/test_generator.py +++ b/tests_basic/core/test_image_generator.py @@ -74,13 +74,13 @@ def profiles_from_simulator( y_pixel = int(round(simulator.shape[0] * y_position)) x_pixel = int(round(simulator.shape[1] * x_position)) inplane_profile = SingleProfile( - img[:, x_pixel], + img[:, x_pixel].copy(), dpmm=img.dpmm, interpolation=interpolation, normalization_method=Normalization.NONE, ) cross_profile = SingleProfile( - img[y_pixel, :], + img[y_pixel, :].copy(), dpmm=img.dpmm, interpolation=interpolation, normalization_method=Normalization.NONE, @@ -457,7 +457,7 @@ def test_10mm_150sid(self): class TestFFFLayer(TestCase): def test_10x10_100sid(self): - for sim in (AS1000Image, AS1200Image): + for sim in (AS500Image, AS1000Image, AS1200Image): as1200 = sim(sid=1000) as1200.add_layer(FilterFreeFieldLayer(field_size_mm=(100, 100))) # no interpolation @@ -488,7 +488,7 @@ def test_10x10_100sid(self): ) # spline interp as1200.add_layer( - GaussianFilterLayer(sigma_mm=0.2) + GaussianFilterLayer(sigma_mm=0.5) ) # spline causes ringing artifacts for ultra-sharp gradients, this is also more realistic anyway inplane_profile, cross_profile = profiles_from_simulator( as1200, interpolation=Interpolation.SPLINE @@ -505,7 +505,7 @@ def test_10x10_100sid(self): ) def test_10x10_150sid(self): - for sim in (AS1000Image, AS1200Image): + for sim in (AS500Image, AS1000Image, AS1200Image): as1200 = sim(sid=1000) as1200.add_layer(FilterFreeFieldLayer(field_size_mm=(150, 150))) inplane_profile, cross_profile = profiles_from_simulator( @@ -611,6 +611,15 @@ def test_save_dicom(self): self.assertEqual(ds.BeamLimitingDeviceAngle, 33) self.assertEqual(ds.PatientSupportAngle, 5) + def test_invert_array(self): + sim = self.simulator() + sim.add_layer(PerfectFieldLayer(field_size_mm=(100, 100))) + ds = sim.as_dicom(invert_array=False) + # when false, the array retains the same values + # the corner should be lower than the center, where dose was delivered + mid = sim.image.shape[0] // 2 + self.assertGreater(ds.pixel_array[mid, mid], ds.pixel_array[0, 0]) + class TestAS500(SimulatorTestMixin, TestCase): simulator = AS500Image @@ -647,3 +656,6 @@ def test_save_dicom(self): sim.generate_dicom( tf.name, gantry_angle=12, coll_angle=33, table_angle=5 ) + + def test_invert_array(self): + pass # method not implemented