Skip to content

Commit

Permalink
Merged in feature/allow_simulator_image_inversion (pull request #388)
Browse files Browse the repository at this point in the history
add parameters and optimize the image simulator

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed May 29, 2024
2 parents eceed74 + e716bb2 commit c0b4dcc
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 57 deletions.
6 changes: 6 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------

Expand Down
93 changes: 41 additions & 52 deletions pylinac/core/image_generator/simulators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand All @@ -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)


Expand All @@ -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"]
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

0 comments on commit c0b4dcc

Please sign in to comment.