"""
NAME
Image module
DESCRIPTION
This module holds functionalities for tif image loading and manipulation.
ArryaImage class is used as representation of dose distributions.
The content is heavily based from
`pylinac <https://pylinac.readthedocs.io/en/latest/_modules/pylinac/core/image.html>`_, and `omg_dosimetry <https://omg-dosimetry.readthedocs.io/en/latest/>`_
"""
from pathlib import Path
import numpy as np
import os.path as osp
from typing import Any, Union
import imageio.v3 as iio
from abc import ABC, abstractmethod
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from skimage.color import rgb2gray
from skimage.filters import threshold_otsu
from skimage.morphology import square, erosion
from skimage.measure import label, regionprops
from skimage.filters.rank import mean
from skimage.transform import rotate
from .tools.resol import equate_resolution
from .tools.files_to_image import equate_array_size
import math
from .calibration import polynomial_g3, rational_func, Calibration
from .i_o import retrieve_dicom_file, is_dicom_image
MM_PER_INCH = 25.4
ImageLike = Union["ArrayImage", "TiffImage", "CalibImage"]
[docs]
def load(path: str | Path | np.ndarray,
for_calib: bool = False,
filter: int | None = None,
**kwargs) -> "ImageLike":
r"""Load a DICOM image, TIF image, or numpy 2D array.
Parameters
----------
path : str, file-object
The path to the image file or array.
for_calib : bool, default = False
True if the image is going to be used to get a calibration curve.
filter : int
If None (default), no filtering will be done to the image.
If an int, will perform median filtering over image of size ``filter``.
kwargs
See :class:`~Dosepy.image.ArrayImage`, :class:`~Dosepy.image.TiffImage`,
or :class:`~Dosepy.image.CalibImage` for keyword arguments.
Returns
-------
::class:`~Dosepy.image.ArrayImage`, :class:`~Dosepy.image.TiffImage`
Examples
--------
Load an image from a file::
>>> from Dosepy.image import load
>>> path_to_image = r"C:\QA\image.tif"
>>> img = load(path_to_image) # returns a TiffImage
Loading from an array is just like loading from a file::
>>> arr = np.arange(36).reshape(6, 6)
>>> img = load(arr) # returns an ArrayImage
"""
if isinstance(path, BaseImage):
return path
if _is_array(path):
array_image = ArrayImage(path, **kwargs)
if isinstance(filter, int):
array_image.array = mean(array_image.array, footprint=square(filter))
return array_image
elif _is_dicom(path):
ds = retrieve_dicom_file(path)
array = ds.pixel_array
#image_orientation = DS.ImageOrientationPatient
if array.ndim != 2:
raise Exception("The DICOM file must have 2D dose distribution.")
dgs = ds.DoseGridScaling
d_array = array * dgs
resolution_mm = ds.PixelSpacing
if resolution_mm[0] != resolution_mm[1]:
raise Exception("Pixel spacing must be equal in both dimensions.")
return ArrayImage(d_array, dpi=MM_PER_INCH/resolution_mm[0])
elif _is_image_file(path):
if _is_tif_file(path):
if _is_RGB(path):
if for_calib:
calib_image = CalibImage(path, **kwargs)
if isinstance(filter, int):
for i in range(3):
calib_image.array[:, :, i] = mean(
calib_image.array[:, :, i],
footprint=square(filter))
return calib_image
else:
tiff_image = TiffImage(path, **kwargs)
if isinstance(filter, int):
for i in range(3):
tiff_image.array[:, :, i] = mean(
tiff_image.array[:, :, i],
footprint=square(filter))
return tiff_image
else:
raise TypeError(f"The argument '{path}' was not found to be\
a RGB TIFF file.")
else:
raise TypeError(f"The argument '{path}' was not found to be\
a valid TIFF file.")
else:
raise TypeError(f"The argument '{path}' was not found to be\
a valid file.")
[docs]
def load_images(paths: list):
"""
Parameters
----------
paths : list
List with the paths to the TIFF files.
Return
------
list of TIffImage
"""
images = []
for file in paths:
images.append(load(file))
return images
def _is_array(obj: Any) -> bool:
"""Whether the object is a numpy array."""
return isinstance(obj, np.ndarray)
def _is_dicom(path: str | Path) -> bool:
"""Whether the file is a readable DICOM file via pydicom."""
return is_dicom_image(file=path)
def _is_image_file(path: str | Path) -> bool:
"""Whether the file is a readable image file via imageio.v3."""
try:
iio.improps(path)
return True
except:
return False
def _is_tif_file(path: str | Path) -> bool:
"""Whether the file is a tif image file."""
if Path(path).suffix in (".tif", ".tiff"):
return True
else:
return False
def _is_RGB(path: str | Path) -> bool:
"""Whether the image is RGB."""
img_props = iio.improps(path)
if len((img_props.shape)) == 3 and img_props.shape[2] == 3:
return True
else:
return False
[docs]
class BaseImage(ABC):
"""Base abstract class for the Image classes.
Attributes
----------
path : str
The path to the image file.
array : numpy.ndarray
The actual image pixel array.
"""
array: np.ndarray
path: str | Path
def __init__(self, path: str | Path | np.ndarray):
"""
Parameters
----------
path : str
The path to the image.
"""
if isinstance(path, (str, Path)) and not osp.isfile(path):
raise FileExistsError(
f"File `{path}` does not exist. Verify the file path name.")
else:
self.path = path
super().__init__()
@property
def physical_shape(self) -> tuple[float, float]:
"""The physical size of the image in mm."""
return self.shape[0] / self.dpmm, self.shape[1] / self.dpmm
@property
def shape(self) -> tuple[int, int]:
return self.array.shape
def as_type(self, dtype: np.dtype) -> np.ndarray:
return self.array.astype(dtype)
[docs]
def crop(
self,
pixels: int = 15,
edges: tuple[str, ...] = ("top", "bottom", "left", "right"),
) -> None:
"""Removes pixels on all edges of the image in-place.
Parameters
----------
pixels : int
Number of pixels to cut off all sides of the image.
edges : tuple
Which edges to remove from. Can be any combination of the four edges.
"""
if pixels <= 0:
raise ValueError("Pixels to remove must be a positive number")
if "top" in edges:
self.array = self.array[pixels:, :]
if "bottom" in edges:
self.array = self.array[:-pixels, :]
if "left" in edges:
self.array = self.array[:, pixels:]
if "right" in edges:
self.array = self.array[:, :-pixels]
[docs]
def flipud(self) -> None:
"""Flip the image array upside down. Wrapper for np.flipud()"""
self.array = np.flipud(self.array)
[docs]
def fliplr(self) -> None:
"""Flip the image array in the left/right direction. Wrapper for np.fliplr()"""
self.array = np.fliplr(self.array)
[docs]
def rotate(self, angle: float, mode: str = "edge", *args, **kwargs):
"""Rotate the image counter-clockwise. Simple wrapper for scikit-image. See https://scikit-image.org/docs/stable/api/skimage.transform.html#skimage.transform.rotate.
All parameters are passed to that function."""
self.array = rotate(self.array, angle, mode=mode, *args, **kwargs)
[docs]
class TiffImage(BaseImage):
"""An image from a tiff file.
Attributes
----------
sid : float
The SID value as passed in upon construction.
props : imageio.core.v3_plugin_api.ImageProperties
Image properties via imageio.v3.
"""
def __init__(
self,
path: str | Path,
*,
dpi: float | None = None,
sid: float | None = None,
):
"""
Parameters
----------
path : str, file-object
The path to the file or a data stream.
dpi : int, float
The dots-per-inch of the image, defined at isocenter.
.. note:: If a X and Y Resolution tag is found in the image, that
value will override the parameter, otherwise this one will be used.
sid : int, float
The Source-to-Image distance in mm.
label_img : numpy.adarray
Label image regions.
"""
super().__init__(path)
self.props = iio.improps(path)
self.array = iio.imread(path)
try:
dpi = self.props.spacing[0]
except AttributeError:
pass
self._dpi = dpi
self.sid = sid
self.label_image = np.array([])
self.number_of_films = None
@property
def dpi(self) -> float | None:
"""The dots-per-inch of the image, defined at isocenter."""
dpi = None
if self.sid is not None:
dpi *= self.sid / 1000
else:
dpi = self._dpi
return dpi
@property
def dpmm(self) -> float | None:
"""The Dots-per-mm of the image, defined at isocenter. E.g. if an EPID
image is taken at 150cm SID, the dpmm will scale back to 100cm."""
try:
return self.dpi / MM_PER_INCH
except TypeError:
return
[docs]
def get_stat(
self,
ch='G',
roi=(5, 5),
show=False,
) -> list:
r"""Get average and standar deviation from pixel values at a central ROI in each film.
Parameter
---------
ch : str
Color channel. "R": Red, "G": Green, "B": Blue and "M": mean.
field_in_film : bool
True to show the rois used in the image.
roi : tuple
Width and height of a region of interest (roi) in millimeters, at the
center of the film.
show : bool
Whether to actually show the image and rois.
Returns
-------
list
mean, std
Examples
--------
Load an image from a file and compute a calibration curve using
green channel::
>>> from dosepy.image import load
>>> path_to_image = r"C:\QA\image.tif"
>>> cal_image = load(path_to_image, for_calib = True)
>>> mean, std = cal_image.get_stat(ch = 'G', roi=(5,5), show = True)
>>> list(zip(mean, std))
"""
if not self.label_image.any():
self.set_labeled_img(threshold = 0.90)
if show:
fig, axes = plt.subplots(ncols=1)
#ax = axes.ravel()
axes = plt.subplot(1, 1, 1)
#axes.imshow(gray_scale, cmap="gray")
axes.imshow(self.array/np.max(self.array))
#print(f"Number of images detected: {num}")
# Films
if ch in ["R", "Red", "r", "red"]:
films = regionprops(self.label_image, intensity_image=self.array[:, :, 0])
elif ch in ["G", "Green", "g", "green"]:
films = regionprops(self.label_image, intensity_image=self.array[:, :, 1])
elif ch in ["B", "Blue", "b", "blue"]:
films = regionprops(self.label_image, intensity_image=self.array[:, :, 2])
elif ch in ["M", "Mean", "m", "mean"]:
films = regionprops(self.label_image,
intensity_image=np.mean(self.array, axis=2)
)
else:
print("Channel not founded")
# Find the unexposed film.
#mean_pixel = []
#for film in films:
# mean_pixel.append(film.intensity_mean)
#index_ref = mean_pixel.index(max(mean_pixel))
#print(f"Index reference: {index_ref}")
#end Find the unexposed film.
mean = []
std = []
height_roi_pix = int(roi[1]*self.dpmm)
#print(f"height_roi: {height_roi_pix}")
width_roi_pix = int(roi[0]*self.dpmm)
#print(f"width_roi: {width_roi_pix}")
for film in films:
x0, y0 = film.centroid
#print(f"(x0: {x0}, y0: {y0}")
# Used to get film rectangle.
#minr_film, minc_film, maxr_film, maxc_film = film.bbox
minc_roi = int(y0 - width_roi_pix/2)
minr_roi = int(x0 - height_roi_pix/2)
if ch in ["R", "Red", "r", "red"]:
roi = self.array[
int(x0 - height_roi_pix/2): int(x0 + height_roi_pix/2),
int(y0 - width_roi_pix/2): int(y0 + width_roi_pix/2),
0,
]
elif ch in ["G", "Green", "g", "green"]:
roi = self.array[
int(x0 - height_roi_pix/2): int(x0 + height_roi_pix/2),
int(y0 - width_roi_pix/2): int(y0 + width_roi_pix/2),
1,
]
elif ch in ["B", "Blue", "b", "blue"]:
roi = self.array[
int(x0 - height_roi_pix/2): int(x0 + height_roi_pix/2),
int(y0 - width_roi_pix/2): int(y0 + width_roi_pix/2),
2,
]
elif ch in ["M", "Mean", "m", "mean"]:
array = np.mean(self.array, axis=2)
roi = array[
int(x0 - height_roi_pix/2): int(x0 + height_roi_pix/2),
int(y0 - width_roi_pix/2): int(y0 + width_roi_pix/2)
]
mean.append(int(np.mean(roi)))
std.append(int(np.std(roi)))
if show:
rect_roi = mpatches.Rectangle(
(minc_roi, minr_roi),
width_roi_pix,
height_roi_pix,
fill=False,
edgecolor='red',
linewidth=1,
)
axes.add_patch(rect_roi)
if show:
plt.show()
return mean, std
[docs]
def plot(
self,
ax: plt.Axes = None,
show: bool = True,
clear_fig: bool = False,
**kwargs
) -> plt.Axes:
"""Plot the image.
Parameters
----------
ax : matplotlib.Axes instance
The axis to plot the image to. If None, creates a new figure.
show : bool
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.
kwargs
kwargs passed to plt.imshow()
"""
if ax is None:
fig, ax = plt.subplots()
if clear_fig:
plt.clf()
ax.imshow(self.array/np.max(self.array), **kwargs)
#ax.imshow(self.array[:,:,0], **kwargs)
if show:
plt.show()
return ax
[docs]
def to_dose(self, cal, clip=False):
"""Convert the tiff image to a dose distribution. The tiff file image
has to contain an unirradiated film used as a reference for zero Gray.
Parameters
----------
cal : :class:`~Dosepy.calibration.Calibration`
Instance of a Calibration class
clip : bool, default: False
If True, limit the maximum dose to the greatest used for calibration. Useful to avoid very high doses.
Returns
-------
:class:`~Dosepy.image.ArrayImage`
Dose distribution.
"""
mean_pixel, _ = self.get_stat(
ch=cal.channel,
roi=(5, 5),
show=False,
)
mean_pixel = sorted(mean_pixel, reverse=True)
if cal.channel in ["R", "Red", "r", "red"]:
if cal.func in ["P3", "Polynomial"]:
x = -np.log10(self.array[:, :, 0]/mean_pixel[0])
elif cal.func in ["RF", "Rational"]:
x = self.array[:, :, 0]/mean_pixel[0]
elif cal.channel in ["G", "Green", "g", "green"]:
if cal.func in ["P3", "Polynomial"]:
x = -np.log10(self.array[:, :, 1]/mean_pixel[0])
elif cal.func in ["RF", "Rational"]:
x = self.array[:, :, 1]/mean_pixel[0]
elif cal.channel in ["B", "Blue", "b", "blue"]:
if cal.func in ["P3", "Polynomial"]:
x = -np.log10(self.array[:, :, 2]/mean_pixel[0])
elif cal.func in ["RF", "Rational"]:
x = self.array[:, :, 2]/mean_pixel[0]
elif cal.channel in ["M", "Mean", "m", "mean"]:
array = np.mean(self.array, axis=2)
if cal.func in ["P3", "Polynomial"]:
x = -np.log10(array/mean_pixel[0])
elif cal.func in ["RF", "Rational"]:
x = self.array/mean_pixel[0]
if cal.func in ["P3", "Polynomial"]:
dose_image = polynomial_g3(x, *cal.popt)
elif cal.func in ["RF", "Rational"]:
dose_image = rational_func(x, *cal.popt)
dose_image[dose_image < 0] = 0 # Remove unphysical doses < 0
if clip: # Limit the maximum dose
max_calib_dose = cal.doses[-1]
dose_image[dose_image > max_calib_dose] = max_calib_dose
return load(dose_image, dpi=self.dpi)
[docs]
def doses_in_central_rois(self, cal, roi, show):
"""Dose in central film rois.
Parameters
----------
cal : Dosepy.calibration.Calibration
Instance of a Calibration class
roi : tuple
Width and height of a region of interest (roi) in millimeters (mm), at the
center of the film.
show : bool
Whether to actually show the image and rois.
Returns
-------
array : numpy.ndarray
Doses on heach founded film.
"""
mean_pixel, _ = self.get_stat(ch=cal.channel, roi=roi, show=show)
#
if cal.func in ["P3", "Polynomial"]:
mean_pixel = sorted(mean_pixel) # Film response.
optical_density = -np.log10(mean_pixel/mean_pixel[0])
dose_in_rois = polynomial_g3(optical_density, *cal.popt)
elif cal.func in ["RF", "Rational"]:
# Pixel normalization
mean_pixel = sorted(mean_pixel, reverse = True)
norm_pixel = np.array(mean_pixel)/mean_pixel[0]
dose_in_rois = rational_func(norm_pixel, *cal.popt)
return dose_in_rois
def set_labeled_img(self, threshold=None):
erosion_pix = int(6*self.dpmm) # Number of pixels used for erosion.
#print(f"Number of pixels to remove borders: {erosion_pix}")
gray_scale = rgb2gray(self.array)
if not threshold:
thresh = threshold_otsu(gray_scale) # Used for films identification.
else:
thresh = threshold * np.amax(gray_scale)
binary = erosion(gray_scale < thresh, square(erosion_pix))
self.label_image, self.number_of_films = label(binary, return_num=True)
[docs]
class CalibImage(TiffImage):
"""A tiff image used for calibration."""
def __init__(self, path: str | Path, **kwargs):
"""
Parameters
----------
path : str, file-object
The path to the file.
dpi : float
The dots-per-inch of the image, defined at isocenter.
.. note:: If a DPI tag is found in the image, that value will
override the parameter, otherwise this one will be used.
"""
super().__init__(path, **kwargs)
self.calibration_curve_computed = False
[docs]
def get_calibration(
self,
doses: list,
func="P3",
channel="R",
roi=(5, 5),
threshold=None,
):
r"""Computes calibration curve. Use non-linear least squares to
fit a function, func, to data. For more information see
scipy.optimize.curve_fit.
Parameter
---------
doses : list
Doses values used to expose films for calibration.
func : string
"P3": Polynomial function of degree 3, using optical density as film response. "RF" or "Rational": Rational function, using normalized pixel value relative to the unexposed film.
channel : str
Color channel. "R": Red, "G": Green and "B": Blue, "M": mean.
roi : tuple
Width and height region of interest (roi) in millimeters, at the
center of the film.
Returns
-------
::class:`~Dosepy.calibration.Calibration`
Instance of a Calibration class.
Examples
--------
Load an image from a file and compute a calibration curve using green
channel::
>>> from Dosepy.image import load
>>> path_to_image = r"C:\QA\image.tif"
>>> cal_image = load(path_to_image, for_calib = True)
>>> cal = cal_image.get_calibration(doses = [0, 0.5, 1, 2, 4, 6, 8, 10], channel = "G")
>>> # Plot the calibration curve
>>> cal.plot()
"""
doses = sorted(doses)
mean_pixel, _ = self.get_stat(ch=channel, roi=roi)
mean_pixel = sorted(mean_pixel, reverse=True)
mean_pixel = np.array(mean_pixel)
if func in ["P3", "Polynomial"]:
x = -np.log10(mean_pixel/mean_pixel[0]) # Optical density
elif func in ["RF", "Rational"]:
x = mean_pixel/mean_pixel[0]
return Calibration(y=doses, x=x, func=func, channel=channel)
[docs]
class ArrayImage(BaseImage):
"""An image constructed solely from a numpy array."""
def __init__(
self,
array: np.ndarray,
*,
dpi: float = None,
sid: float = None,
dtype=None,
):
"""
Parameters
----------
array : numpy.ndarray
The image array.
dpi : int, float
The dots-per-inch of the image, defined at isocenter.
.. note:: If a DPI tag is found in the image, that value will
override the parameter, otherwise this one will be used.
sid : int, float
The Source-to-Image distance in mm.
dtype : dtype, None, optional
The data type to cast the image data as. If None, will use
whatever raw image format is.
"""
if dtype is not None:
self.array = np.array(array, dtype=dtype)
else:
self.array = array
self._dpi = dpi
self.sid = sid
@property
def dpmm(self) -> float | None:
"""The Dots-per-mm of the image, defined at isocenter."""
try:
return self.dpi / MM_PER_INCH
except:
return
@property
def dpi(self) -> float | None:
"""The dots-per-inch of the image, defined at isocenter."""
dpi = None
if self._dpi is not None:
dpi = self._dpi
if self.sid is not None:
dpi *= self.sid / 1000
return dpi
@property
def physical_shape(self):
"""The physical size of the image in mm."""
return self.shape[0] / self.dpmm, self.shape[1] / self.dpmm
[docs]
def save_as_tif(self, file_name):
"""Used to save a dose distribution (in Gy) as a tif file (in cGy).
Parameters
----------
file_name : str
File name as a string
"""
np_tif = self.array.astype(np.uint16)
tif_encoded = iio.imwrite(
"<bytes>",
np_tif,
extension=".tif",
resolution = (self.dpi, self.dpi),
)
with open(file_name, 'wb') as f:
f.write(tif_encoded)
[docs]
def gamma2D(self,
reference,
dose_ta=3,
dist_ta=3,
*,
dose_threshold=10,
dose_ta_Gy=False,
local_norm=False,
mask_radius=10,
max_as_percentile=True,
exclude_above=None
):
'''
Calculate gamma between the current image against a reference image.
The images must have the same spatial resolution (dpi) to be comparable. The size of the images must also be the same.
An array is obtained. It represents the gamma indices at each position of the dose distribution,
as well as the approval rate defined as the percentage of gamma values that are less or equal to 1.
The registration of dose distributions is assumed, i.e. the spatial coordinate of a point in the
reference dose distribution is equal to the coordinate of the same point in the distribution to be evaluated.
Parameters
----------
reference ::class:`~Dosepy.image.ArrayImage`
The reference image.
dose_ta : float, default = 3
Dose-to-agreement.
This value can be interpreted in 3 different ways depending on the dose_ta_Gy,
local_norm and max_as_percentile parameters, which are described below.
dist_ta : float, default = 3
Distance-to-agreement in mm.
dose_threshold : float, default = 10
Dose threshold in percentage (0 to 100) with respect to the maximum dose of the
reference distribution (or to 99th percentile if max_as_percentile = True).
Any point in the dose distribution with a value less than the dose threshold,
is excluded from the analysis.
dose_ta_Gy : bool, default: False
If True, then "dose_ta" (the tolerance dose) is interpreted as an absolute value in Gray.
If False (default), "dose_ta" is interpreted as a percentage.
local_norm : bool, default: False
If the argument is True (local normalization), the tolerance dose percentage "dose_ta" is interpreted with respect to the local dose
at each point of the reference distribution.
If the argument is False (global normalization), the tolerance dose percentage "dose_ta" is interpreted with respect to the
maximum of the distribution to be evaluated.
* The dose_ta_Gy and local_norm arguments must NOT be selected as True simultaneously.
* If you want to use the maximum of the distribution directly, use the parameter max_as_percentile = False (see explanation below).
mask_radius : float, default: 10
Physical distance in millimeters used to limit the calculation to positions that are within a neighborhood given by mask_radius.
The use of this mask allows reducing the calculation time due to the following process:
For each point in the reference distribution, the calculation of the Gamma function is performed only
with those points or positions of the distribution to be evaluated that are at a relative distance
less than or equal to mask_radius, that is, with the points that are within the neighborhood given by mask_radius.
The length of one side of the square mask is 2*mask_radius + 1.
On the other hand, if you prefer to compare with all the points of the distribution to be evaluated, it is enough to enter
a distance greater than the dimensions of the dose distribution (for example mask_radius = 1000).
max_as_percentile : bool, default: True
If the argument is True, 99th percentile is used as an approximation of the maximum value of the
dose distribution. This allows us to exclude artifacts or errors in specific positions.
If the argument is False, the maximum value of the distribution is used.
exclude_above : float, default: None
Dose limit in Gy. Any point in the evaluated distribution greater than exclude_above, is not accounted in the pass rate. dose_ta_Gy should be set as True.
Returns
-------
gamma_map : numpy.ndarray
The calculated gamma distribution.
pass_rate : float
Approval rate. It is calculated as the percentage of gamma values <= 1.
Points with dose below than the dose threshold are not accounted.
Notes
-----
Percentile 99th of the dose distribution can be used as an approximation of the maximum value.
This allows us to avoid artifacts or errors in specific positions of the distribution.
(useful for example for spot labels are used in films).
It is assumed that both distributions have exactly the same physical dimensions, and the positions
for each point coincide with each other, that is, the images are registered.
Interpolation is not supported yet.
**References**
For more information about the operating mechanisms, effectiveness and accuracy of the gamma tool:
[1] M. Miften, A. Olch, et. al. "Tolerance Limits and Methodologies for IMRT Measurement-Based
Verification QA: Recommendations of AAPM Task Group No. 218" Medical Physics, vol. 45, nº 4, pp. e53-e83, 2018.
[2] D. Low, W. Harms, S. Mutic y J. Purdy, «A technique for the quantitative evaluation of dose distributions,»
Medical Physics, vol. 25, nº 5, pp. 656-661, 1998.
[3] L. A. Olivares-Jimenez, "Distribución de dosis en radioterapia de intensidad modulada usando películas de tinte
radiocrómico : irradiación de cerebro completo con protección a hipocampo y columna con protección a médula"
(Tesis de Maestría) Posgrado en Ciencias Físicas, IF-UNAM, México, 2019
Examples
--------
Numpy arrays as dose distributions::
>>> # We import the Dosepy packages as well as numpy to create example arrays representing two dose distributions.
>>> from Dosepy.image import load
>>> import numpy as np
>>> # We generate the arrays, A and B, with the values 96 and 100 in all their elements.
>>> A = np.zeros((30, 30)) + 96
>>> B = np.zeros((30, 30)) + 100
>>> # We generate the dose distributions
>>> D_ref = load(A, dpi = 25.4)
>>> D_eval = load(B, dpi = 25.4)
>>> # On the variable D_eval, we apply the gamma2D method providing as arguments the reference distribution, D_ref, and the criteria (3%, 1 mm).
>>> gamma_distribution, pass_rate = D_eval.gamma2D( D_ref, 3, 1)
>>> print(f"Pass rate: {pass_rate:.1f} %")
CSV files (comma separated values)::
>>> from Dosepy.image import load
>>> # Load "D_TPS.csv" y "D_FILM.csv"
>>> # The example .csv files are located within the Dosepy package, in the src/Dosepy/data
>>> np_film = np.genfromtxt('../D_FILM.csv', delimiter = ",", comments = "#")
>>> np_tps = np.genfromtxt('../D_TPS.csv', delimiter = ",", comments = "#")
>>> d_film = load(np_film, dpi=25.4)
>>> d_tps = load(np_tps, dpi=25.4)
We call the method gamma2D, with criteria 3%, 2 mm::
>>> g, pass_rate = d_tps.gamma2D(d_film, 3, 2)
>>> # Print the result
>>> print(f'Pass rate: {pass_rate:.1f} %')
>>> plt.imshow(g, vmax = 1.4)
>>> plt.show()
>>> # Pass rate: 98.9 %
'''
#%%
# error checking
if reference.shape != self.shape:
raise AttributeError(
f"The images are not the same size: {self.shape} vs. {reference.shape}"
)
if local_norm and dose_ta_Gy:
raise AttributeError(
"Simultaneous selection of dose_ta_Gy and local_norm is not possible."
)
if not self.dpi:
raise AttributeError(
"The distribution has no associated spatial resolution."
)
if reference.dpi != self.dpi:
raise AttributeError(
f"The image DPIs to not match: {self.dpi:.2f} vs. {reference.dpi:.2f}"
)
#%%
D_ref = reference.array
D_eval = self.array
if max_as_percentile:
maximum_dose = np.percentile(D_eval, 99)
else:
maximum_dose = np.amax(D_eval)
#print(f'Maximum dose: {maximum_dose:.1f}')
# Umbral de dosis
Dose_threshold = (dose_threshold/100)*maximum_dose
#print(f'Dose_threshold: {Dose_threshold:.1f}')
# Absolute or relative dose-to-agreement
if dose_ta_Gy:
pass
elif local_norm:
pass
else:
dose_ta = (dose_ta/100) * maximum_dose
# Number of pixels that will be used to define a neighborhood
# over which the gamma index will be calculated.
neighborhood = round(mask_radius*self.dpmm)
# Array that will store the result of the gamma index.
gamma = np.zeros( (self.array.shape[0], self.array.shape[1]) )
#%%
for i in np.arange( D_ref.shape[0] ):
# Code that allows including points near the border of the dose distribution
mi = -(neighborhood - max(0, neighborhood - i))
mf = neighborhood - max(0, neighborhood - (D_eval.shape[0] - (i+1))) + 1
for j in np.arange( D_ref.shape[1] ):
ni = -(neighborhood - max(0, neighborhood - j))
nf = neighborhood - max(0, neighborhood - (D_eval.shape[1] - (j+1))) + 1
# To temporarily store the Gamma function values for
# each point in the reference distribution
Gamma = []
for m in np.arange(mi , mf):
for n in np.arange(ni, nf):
# Row physical distance (mm)
dm = m*(1./self.dpmm)
# Column physical distance (mm)
dn = n*(1./self.dpmm)
# Distance between two points
distance = np.sqrt(dm**2 + dn**2)
# Dose difference
dose_dif = D_eval[i + m, j + n] - D_ref[i,j]
if local_norm:
# The dose-to-agreement is updated to the percentage
# with respect to the value
# of local dose in the reference distribution.
dose_t_local = dose_ta * D_ref[i,j] / 100
Gamma.append(
np.sqrt(
(distance**2) / (dist_ta**2)
+ (dose_dif**2) / (dose_t_local**2))
)
else :
Gamma.append(
np.sqrt(
(distance**2) / (dist_ta**2)
+ (dose_dif**2) / (dose_ta**2))
)
gamma[i,j] = min(Gamma)
# For the position in question, if the dose is below than the dose threshold, or above exclude_above,
# then this point is not taken into account in the approval percentage.
if D_eval[i,j] < Dose_threshold:
gamma[i,j] = np.nan
if exclude_above:
if D_eval[i,j] > exclude_above:
gamma[i,j] = np.nan
# Returns the coordinates where the gamma values are less than or equal to 1
less_than_1_coordinate = np.where(gamma <= 1)
# Counts the number of coordinates where gamma <= 1 is True
less_than_1 = np.shape(less_than_1_coordinate)[1]
# Number of values that are not np.nan
total_points = gamma.size - np.isnan(gamma).sum(where=True)
# Pass rate
gamma_percent = float(less_than_1)/total_points*100
return gamma, gamma_percent
[docs]
def reduce_resolution_as(self, reference):
"""
Reduce the spatial resolution of the image to have the same a reference image. Usefull for gamma analysis.
The physical dimensions of the images must be the same (within half of the reference resolution).
The algorithm averages a number of pixels given by reference_resolution // image_resolution.
Parameters
----------
reference : :class:`~Dosepy.image.ArrayImage`
The reference image that has the target resolution.
Raises
------
AttributeError
If the physical dimensions of the images are not the same.
Examples
--------
Create two images with different resolutions and reduce the resolution of one of them::
>>> from Dosepy.image import load
>>> import numpy as np
>>> # Generate the arrays, A and B.
>>> A = np.random.rand(100, 100)
>>> B = np.random.rand(10, 10)
>>> # Create the dose distributions.
>>> D_eval = load(A, dpi = 10)
>>> D_ref = load(B, dpi = 1)
>>> # Reduce the resolution of the image D_eval to have the same resolution as D_ref.
>>> D_eval.reduce_resolution_as(D_ref)
>>> # Print the new shape of the D_eval array.
>>> print(D_eval.shape) # (10, 10)
"""
# Check that reference has a higher resolution
if reference.dpi > self.dpi:
raise AttributeError(
"The reference image must have a higher resolution than the image to be reduced."
)
elif reference.dpi == self.dpi:
print("The spatial resolution of both images is the same.")
return
## Check if the physical dimensions are the same within a tolerance
if not math.isclose(self.physical_shape[0], reference.physical_shape[0], abs_tol = 1./reference.dpmm/2):
raise AttributeError(
"The physical dimensions of the images are not the same."
)
# Average pixels to reduce resolution
self.array = equate_resolution(
array = self.array,
array_resolution = 1/self.dpmm,
target_resolution = 1./reference.dpmm
)
# Equate array shape
reduced_img, _ = equate_array_size([self, reference])
self.array = reduced_img.array
self._dpi = reference.dpi
[docs]
class DoseImage(ArrayImage):
""" A dose distribution image."""
def __init__(
self,
array: np.ndarray,
dpi: float,
reference_point: list[float, float],
orientation: tuple[int, int, int, int, int, int],
dose_unit: str,
):
super().__init__(array, dpi=dpi)
self._reference_point = reference_point
self._orientation = orientation
self._dose_unit = dose_unit