Skip to content

Commit

Permalink
Merged in refactor/wl_pre (pull request #355)
Browse files Browse the repository at this point in the history
WL refactor pre-step

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Mar 25, 2024
2 parents 54af6eb + c8d6371 commit e236f14
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 8 deletions.
19 changes: 19 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@
Changelog
=========

v 3.22.0
--------

Image Metrics
^^^^^^^^^^^^^

* The ``GlobalSizedDiskLocator`` class has added an ``invert`` parameter. This parameter existed for the other locators, but was missing for the global disk locator.
Previously, the locator was always inverting the image (assuming images like EPID). Now, the parameter can be used to control this behavior. By
default, the paramter is true for backwards-compatibility.

Image
^^^^^

* It is now possible to save ``XIM`` images back to a *simplified* DICOM dataset. A new method has been added: ``as_dicom`` which will
return a pydicom Dataset.
* When plotting an image (``DicomImage``, ``ArrayImage``, etc) where metrics had been computed, the metrics would
be plotted on the resulting figure all the time. A new parameter ``show_metrics`` has been added to the ``plot`` method
to control this behavior.

v 3.21.0
--------

Expand Down
70 changes: 65 additions & 5 deletions pylinac/core/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
retrieve_filenames,
)
from .profile import stretch as stretcharray
from .scale import wrap360
from .scale import MachineScale, convert, wrap360
from .utilities import decode_binary, is_close, simple_round

ARRAY = "Array"
Expand Down Expand Up @@ -133,7 +133,7 @@ def is_image(path: str | io.BytesIO | ImageLike | np.ndarray) -> bool:
-------
bool
"""
return any((_is_array(path), _is_dicom(path), _is_image_file(path)))
return any((_is_array(path), _is_dicom(path), _is_image_file(path), _is_xim(path)))


def retrieve_image_files(path: str) -> list[str]:
Expand Down Expand Up @@ -359,6 +359,16 @@ def _is_image_file(path: str | Path) -> bool:
return False


def _is_xim(path: str | Path) -> bool:
"""Whether the file is a readable XIM file."""
try:
with open(path, "rb") as xim:
format_id = decode_binary(xim, str, 8)
return format_id == "VMS.XI"
except Exception:
return False


def _is_array(obj: Any) -> bool:
"""Whether the object is a numpy array."""
return isinstance(obj, np.ndarray)
Expand Down Expand Up @@ -491,6 +501,7 @@ def plot(
ax: plt.Axes = None,
show: bool = True,
clear_fig: bool = False,
show_metrics: bool = True,
metric_kwargs: dict | None = None,
**kwargs,
) -> plt.Axes:
Expand All @@ -504,6 +515,8 @@ def plot(
Whether to actually show the image. Set to false when plotting multiple items.
clear_fig : bool
Whether to clear the prior items on the figure before plotting.
show_metrics : bool
Whether to show the metrics on the image.
metric_kwargs : dict
kwargs passed to the metric plot method.
kwargs
Expand All @@ -517,8 +530,9 @@ def plot(
plt.clf()
ax.imshow(self.array, cmap=get_dicom_cmap(), **kwargs)
# plot the metrics
for metric in self.metrics:
metric.plot(axis=ax, **metric_kwargs)
if show_metrics:
for metric in self.metrics:
metric.plot(axis=ax, **metric_kwargs)
if show:
plt.show()
return ax
Expand Down Expand Up @@ -1109,7 +1123,53 @@ def dpmm(self) -> float:
)
return 1 / (10 * self.properties["PixelHeight"])

def save_as(self, file: str, format: str | None = None) -> None:
def as_dicom(self) -> Dataset:
"""Save the XIM image as a *simplistic* DICOM file. Only meant for basic image storage/analysis.
It appears that XIM images are in the Varian standard coordinate system.
We convert to IEC61217 for more general compatibility.
"""
iec_g, iec_c, iec_p = convert(
input_scale=MachineScale.VARIAN_STANDARD,
output_scale=MachineScale.IEC61217,
gantry=self.properties["GantryRtn"],
collimator=self.properties["MVCollimatorRtn"],
rotation=self.properties["CouchRtn"],
)
uint_array = convert_to_dtype(self.array, np.uint16)
file_meta = FileMetaDataset()
# Main data elements
ds = Dataset()
ds.SOPClassUID = UID("1.2.840.10008.5.1.4.1.1.481.1") # RT Image
ds.SOPInstanceUID = generate_uid()
ds.SeriesInstanceUID = generate_uid()
ds.Modality = "RTIMAGE"
ds.ConversionType = "WSD"
ds.PatientName = "Lutz^Test Tool"
ds.PatientID = "Someone Important"
ds.SamplesPerPixel = 1
ds.PhotometricInterpretation = "MONOCHROME2"
ds.Rows = self.array.shape[0]
ds.Columns = self.array.shape[1]
ds.BitsAllocated = 16
ds.BitsStored = 16
ds.HighBit = 15
ds.PixelRepresentation = 0
ds.ImagePlanePixelSpacing = [1 / self.dpmm, 1 / self.dpmm]
ds.RadiationMachineSAD = "1000.0"
ds.RTImageSID = "1000"
ds.PrimaryDosimeterUnit = "MU"
ds.GantryAngle = f"{iec_g:.2f}"
ds.BeamLimitingDeviceAngle = f"{iec_c:.2f}"
ds.PatientSupportAngle = f"{iec_p:.2f}"
ds.PixelData = uint_array

ds.file_meta = file_meta
ds.is_implicit_VR = True
ds.is_little_endian = True
return ds

def save_as(self, file: str | Path, format: str | None = None) -> None:
"""Save the image to a NORMAL format. PNG is highly suggested. Accepts any format supported by Pillow.
Ironically, an equivalent PNG image (w/ metadata) is ~50% smaller than an .xim image.
Expand Down
2 changes: 1 addition & 1 deletion pylinac/core/scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def mirror_360(value: float) -> float:

def shift_and_mirror_360(value: float) -> float:
"""Shift by 180 degrees and then mirror about 0"""
argue.verify_bounds(value, argue.POSITIVE)
value = abs360(value)
v = value - 180
if v > 0:
return mirror_360(v)
Expand Down
9 changes: 8 additions & 1 deletion pylinac/metrics/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def __init__(
is_right_size_bb,
is_right_circumference,
),
invert: bool = True,
min_number: int = 1,
max_number: int | None = None,
min_separation_mm: float = 5,
Expand All @@ -117,6 +118,8 @@ def __init__(
A list of functions that take a regionprops object and return a boolean.
The functions should be used to determine whether the regionprops object
is a BB.
invert : bool
Whether to invert the image before searching for BBs.
min_number : int
The minimum number of BBs to find. If not found, an error is raised.
max_number : int, None
Expand All @@ -131,14 +134,18 @@ def __init__(
self.radius_tolerance = radius_tolerance_mm
self.detection_conditions = detection_conditions
self.name = name
self.invert = invert
self.min_number = min_number
self.max_number = max_number or 1e3
self.min_separation_mm = min_separation_mm

def calculate(self) -> list[Point]:
"""Find up to N BBs/disks in the image. This will look for BBs at every percentile range.
Multiple BBs may be found at different threshold levels."""
sample = invert(self.image.array)
if self.invert:
sample = invert(self.image.array)
else:
sample = self.image.array
self.points, boundaries, _ = find_features(
sample,
top_offset=0,
Expand Down
30 changes: 29 additions & 1 deletion tests_basic/core/test_scale.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
from pylinac.core.scale import MachineScale, convert
from pylinac.core.scale import (
MachineScale,
convert,
inv_shift_and_mirror_360,
mirror_360,
noop,
shift_and_mirror_360,
)


def test_noop():
assert 5 == noop(5)
assert -5.3 == noop(-5.3)


def test_mirror_360():
assert mirror_360(5) == 355
assert mirror_360(355) == 5


def test_shift_and_mirror_360():
assert 355 == shift_and_mirror_360(185)
assert 185 == shift_and_mirror_360(355)
assert 185 == shift_and_mirror_360(-5)


def test_inv_shift_and_mirror_360():
assert 180 == inv_shift_and_mirror_360(0)
assert 175 == inv_shift_and_mirror_360(5)


def test_iec_to_iec():
Expand Down

0 comments on commit e236f14

Please sign in to comment.