diff --git a/docs/source/v4_changes.rst b/docs/source/v4_changes.rst index bd9f5d63..b78b30be 100644 --- a/docs/source/v4_changes.rst +++ b/docs/source/v4_changes.rst @@ -48,11 +48,20 @@ Core * ``load_url`` * ``.from_url(...)`` class methods. I've not seen a single use case for loading from a URL directly. +Plotting +-------- + * The plotting of analyzed images will migrate from ``matplotlib`` to ``plotly``. This is mainly related to being able to zoom in and otherwise examine an analyzed image more closely. Once plotly is supported, there is no reason to keep both plotting methods around. To reduce maintenance burden, the matplotlib plotting will be removed. - +* Methods involving saving figures (``.save_image(...)``) to file will be removed. Both matplotlib and plotly can + save to file programmatically, and plotly can do so interactively as well. +* Plotting will always produce individual images. Pylinac would sometimes combine plots + in a subplot. This made for annoying figure sizing. Now, each plot will be its own figure. + E.g. for a 2D planar analysis, the plotting method will return up to 3 figures: the image, + the low-contrast plot, and the high-contrast plot. Previously, these would be plotted into + a single 3x1 subplot figure. Open-Field Analysis diff --git a/pylinac/acr.py b/pylinac/acr.py index ea659ea6..241ed0c4 100644 --- a/pylinac/acr.py +++ b/pylinac/acr.py @@ -10,6 +10,7 @@ import numpy as np from matplotlib import pyplot as plt +from plotly import graph_objects as go from pydantic import BaseModel, ConfigDict, Field from scipy import ndimage @@ -41,6 +42,7 @@ class CTModule(CatPhanModule): + common_name = "HU Linearity" attr_name = "ct_calibration_module" roi_dist_mm = 63 roi_radius_mm = 10 @@ -184,6 +186,10 @@ def mtf(self) -> MTF: spacings=spacings, diskset=list(self.rois.values()) ) + def plotly_rois(self, fig: go.Figure) -> None: + for name, roi in self.rois.items(): + roi.plotly(fig, color="green", name=name) + def plot_rois(self, axis: plt.Axes) -> None: """Plot the ROIs to the axis. Override to set the color""" for roi, mtf in zip(self.rois.values(), self.mtf.norm_mtfs.values()): @@ -317,6 +323,44 @@ def analyze(self) -> None: clear_borders=self.clear_borders, ) + def plotly_analyzed_image( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed image using Plotly. Will create multiple figures. + + Parameters + ---------- + show : bool + Whether to show the images. Set to False if doing further processing of the figure. + show_colorbar : bool + Whether to show the colorbar on the images. + show_legend : bool + Whether to show the legend on the images. + kwargs + Additional keyword arguments to pass to the figure. + """ + figs = {} + for module in ( + self.ct_calibration_module, + self.uniformity_module, + self.spatial_resolution_module, + self.low_contrast_module, + ): + figs[module.common_name] = module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + figs["MTF"] = self.spatial_resolution_module.mtf.plotly(**kwargs) + figs["Side View"] = self.plotly_side_view(offset=-0.5) + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs) -> plt.Figure: """Plot the analyzed image @@ -646,6 +690,10 @@ def plot_rois(self, axis: plt.Axes) -> None: for roi in self.rois.values(): roi.plot2axes(axis, edgecolor="blue") + def plotly_rois(self, fig: go.Figure) -> None: + for name, roi in self.rois.items(): + roi.plotly(fig, color="blue", name=name) + class MRSlice11ModuleOutput(BaseModel): """This class should not be called directly. It is returned by the ``results_data()`` method. @@ -739,6 +787,14 @@ def plot_rois(self, axis: plt.Axes) -> None: for roi, mtf in zip(self.rois.values(), self.rois.values()): roi.plot2axes(axis, edgecolor="g") + def plotly_rois(self, fig: go.Figure) -> None: + for name, roi in self.position_rois.items(): + roi.plotly(fig, color="blue", name=name) + for name, roi in self.thickness_rois.items(): + roi.plotly(fig, color="blue", name=name) + for name, roi in self.rois.items(): + roi.plotly(fig, color="green", name=name) + @property def bar_difference_mm(self) -> float: """The difference in height between the two angled bars""" @@ -860,6 +916,11 @@ def plot_rois(self, axis: plt.Axes) -> None: for roi in self.ghost_rois.values(): roi.plot2axes(axis, edgecolor="yellow") + def plotly_rois(self, fig: go.Figure) -> None: + super().plotly_rois(fig) + for name, roi in self.ghost_rois.items(): + roi.plotly(fig, color="yellow", name=name) + @property def percent_image_uniformity(self) -> float: """PIU value calculated via section 5.3 of the manual""" @@ -1012,6 +1073,10 @@ def _setup_rois(self) -> None: "line": line, } + def plotly_rois(self, fig: go.Figure) -> None: + for name, profile_data in self.profiles.items(): + profile_data["line"].plotly(fig, line_width=2, color="blue", name=name) + def plot_rois(self, axis: plt.Axes): for name, profile_data in self.profiles.items(): profile_data["line"].plot2axes(axis, width=2, color="blue") @@ -1188,6 +1253,57 @@ def _select_echo_images(self, echo_number: int | None) -> None: del self.dicom_stack[idx] del self.dicom_stack.metadatas[idx] + def plotly_analyzed_image( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + # plot the images + for module in ( + self.slice1, + self.geometric_distortion, + self.uniformity_module, + self.slice11, + ): + figs[module.common_name] = module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + # side view + figs["Side View"] = self.plotly_side_view(offset=-0.2) + # mtf + fig = go.Figure() + self.slice1.row_mtf.plotly(fig=fig, name="Row-wise rMTF") + figs["MTF"] = self.slice1.col_mtf.plotly( + fig=fig, name="Column-wise rMTF", marker_color="orange" + ) + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs) -> plt.Figure: """Plot the analyzed image diff --git a/pylinac/cheese.py b/pylinac/cheese.py index 3838d289..3b9d76cf 100644 --- a/pylinac/cheese.py +++ b/pylinac/cheese.py @@ -7,9 +7,11 @@ import numpy as np from matplotlib import pyplot as plt +from plotly import graph_objects as go from pydantic import Field from .core import pdf +from .core.plotly_utils import add_title from .core.profile import CollapsedCircleProfile from .core.roi import DiskROI from .core.scale import abs360 @@ -109,6 +111,15 @@ def plot_rois(self, axis: plt.Axes) -> None: for name, roi in self.rois.items(): roi.plot2axes(axis, edgecolor="blue", text=name) + def plotly_rois(self, fig: go.Figure) -> None: + """Plot the ROIs to the figure. We add the ROI # to help the user differentiate""" + for name, roi in self.rois.items(): + roi.plotly( + fig, + name=name, + color="blue", + ) + class TomoCheeseModule(CheeseModule): """The pluggable module with user-accessible holes. @@ -292,6 +303,71 @@ def find_phantom_roll(self, func: Callable | None = None) -> float: ) return 0 + def plotly_analyzed_image( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = { + self.module.common_name: self.module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + } + + # density curve (if densities passed) + if self.roi_config: + xs = [] + ys = [] + for roi_num, roi_data in self.roi_config.items(): + xs.append(roi_data["density"]) + ys.append(self.module.rois[roi_num].pixel_value) + # sort by HU so it looks like a normal curve; ROI densities can be out of order + sorted_args = np.argsort(xs) + xs = np.array(xs)[sorted_args] + ys = np.array(ys)[sorted_args] + # plot + density_fig = go.Figure() + density_fig.add_scatter( + x=xs, + y=ys, + mode="lines+markers", + line_dash="dash", + marker_symbol="diamond", + ) + density_fig.update_layout( + yaxis_title="HU", + xaxis_title="Density", + ) + add_title(density_fig, "Density vs HU curve") + figs["Density vs HU curve"] = density_fig + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs: dict) -> None: """Plot the images used in the calculation and summary data. diff --git a/pylinac/core/geometry.py b/pylinac/core/geometry.py index 68608f91..f925b1ad 100644 --- a/pylinac/core/geometry.py +++ b/pylinac/core/geometry.py @@ -8,6 +8,7 @@ import argue import matplotlib.pyplot as plt import numpy as np +import plotly.graph_objects as go from matplotlib.patches import Circle as mpl_Circle from matplotlib.patches import Rectangle as mpl_Rectangle from mpl_toolkits.mplot3d.art3d import Line3D @@ -134,18 +135,15 @@ def distance_to(self, thing: Point | Circle) -> float: (self.x - p.x) ** 2 + (self.y - p.y) ** 2 + (self.z - p.z) ** 2 ) - def as_array(self, only_coords: bool = True) -> np.ndarray: - """Return the point as a numpy array.""" - if only_coords: - return np.array([getattr(self, item) for item in self._coord_list]) - else: - return np.array( - [ - getattr(self, item) - for item in self._attr_list - if (getattr(self, item) is not None) - ] - ) + def as_array(self, coords: tuple[str, ...] = ("x", "y", "z")) -> np.ndarray: + """Return the point as a numpy array. + + Parameters + ---------- + coords : tuple + The coordinate attributes to return in the array. + """ + return np.array([getattr(self, coord) for coord in coords]) def as_vector(self) -> Vector: return Vector(x=self.x, y=self.y, z=self.z) @@ -247,6 +245,27 @@ def diameter(self) -> float: """Get the diameter of the circle.""" return self.radius * 2 + def plotly( + self, + fig: go.Figure, + color: str = "black", + fill: bool = False, + **kwargs, + ) -> None: + """Draw the circle on a plotly figure.""" + # we use scatter so we can have hovertext/info, etc. Easier + # with add_shape but we don't have the same options. Makes interface more consistent. + theta = np.linspace(0, 2 * np.pi, 100) + fig.add_scatter( + x=self.center.x + self.radius * np.cos(theta), + y=self.center.y + self.radius * np.sin(theta), + mode="lines", + line_color=color, + fill="toself" if fill else "none", + fillcolor=color if fill else "rgba(0,0,0,0)", + **kwargs, + ) + def plot2axes( self, axes: plt.Axes, @@ -499,6 +518,16 @@ def plot2axes( ) return lines[0] + def plotly(self, fig: go.Figure, color: str = "blue", **kwargs) -> None: + """Plot the line to a plotly figure.""" + fig.add_scatter( + x=[self.point1.x, self.point2.x], + y=[self.point1.y, self.point2.y], + mode="lines", + line=dict(color=color), + **kwargs, + ) + def dict(self) -> dict: """Convert to dict. Shim until convert to dataclass""" return { @@ -593,6 +622,44 @@ def tr_corner(self) -> Point: as_int=self._as_int, ) + def plotly( + self, + fig: go.Figure, + color: str = "black", + fill: bool = False, + angle: float = 0.0, + **kwargs, + ) -> None: + """Draw the rectangle on a plotly figure.""" + # we use scatter so we can have hovertext/info, etc. Easier + # with add_shape but we don't have the same options. Makes interface more consistent. + bl_corner, tl_corner, tr_corner, br_corner = rotate_points( + [self.bl_corner, self.tl_corner, self.tr_corner, self.br_corner], + angle, + self.center, + ) + fig.add_scatter( + x=[ + bl_corner.x, + tl_corner.x, + tr_corner.x, + br_corner.x, + bl_corner.x, + ], + y=[ + bl_corner.y, + tl_corner.y, + tr_corner.y, + br_corner.y, + bl_corner.y, + ], + mode="lines", + line_color=color, + fill="toself" if fill else "none", + fillcolor=color if fill else "rgba(0,0,0,0)", + **kwargs, + ) + def plot2axes( self, axes: plt.Axes, @@ -638,6 +705,7 @@ def plot2axes( (self.bl_corner.x, self.bl_corner.y), width=self.width, height=self.height, + rotation_point="center", angle=angle, edgecolor=edgecolor, alpha=alpha, @@ -657,3 +725,21 @@ def plot2axes( horizontalalignment=ha, verticalalignment=va, ) + + +def rotate_points(points: list[Point], angle: float, center: Point) -> list[Point]: + """Rotate a list of points around a center point.""" + angle = np.radians(angle - 90) + # Rotation matrix + rotation_matrix = np.array( + [[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]] + ) + + p = np.asarray([p.as_array(("x", "y")) for p in points]) + # Translate points to origin, apply rotation, translate back + translated_points = p - center.as_array(("x", "y")) + rotated_points = np.dot(translated_points, rotation_matrix) + center.as_array( + ("x", "y") + ) + + return [Point(x, y) for x, y in rotated_points] diff --git a/pylinac/core/image.py b/pylinac/core/image.py index 2ac46d91..033c642c 100644 --- a/pylinac/core/image.py +++ b/pylinac/core/image.py @@ -23,6 +23,7 @@ from PIL import Image as pImage from PIL.PngImagePlugin import PngInfo from PIL.TiffTags import TAGS +from plotly import graph_objects as go from pydicom.dataset import Dataset, FileMetaDataset from pydicom.errors import InvalidDicomError from pydicom.uid import UID, generate_uid @@ -48,6 +49,7 @@ retrieve_dicom_file, retrieve_filenames, ) +from .plotly_utils import add_title from .profile import stretch as stretcharray from .scale import MachineScale, convert, wrap360 from .utilities import decode_binary, is_close, simple_round @@ -494,6 +496,62 @@ def date_created(self, format: str = "%A, %B %d, %Y") -> str: date = "Unknown" return date + def plotly( + self, + fig: go.Figure | None = None, + colorscale: str = "gray", + title: str = "", + show: bool = True, + show_metrics: bool = True, + show_colorbar: bool = True, + **kwargs, + ) -> go.Figure: + """Plot the image in a plotly figure. + + Parameters + ---------- + fig: plotly.graph_objects.Figure + The figure to plot to. If None, a new figure is created. + colorscale: str + The colorscale to use on the plot. See https://plotly.com/python/builtin-colorscales/ + show : bool + Whether to show the plot. Set to False if performing later adjustments to the plot. + show_metrics : bool + Whether to show the metrics on the image. + title: str + The title of the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + kwargs + Additional keyword arguments to pass to the plot. + """ + + if fig is None: + fig = go.Figure() + fig.update_layout( + xaxis_showticklabels=False, + yaxis_showticklabels=False, + # this inverts the y axis so 0 is at the top + # note that this will cause later `range=(...)` calls to fail; + # appears to be bug in plotly. + yaxis_autorange="reversed", + yaxis_scaleanchor="x", + yaxis_constrain="domain", + xaxis_scaleanchor="y", + xaxis_constrain="domain", + legend={"x": 0}, + showlegend=kwargs.pop("show_legend", True), + ) + add_title(fig, title) + fig.add_heatmap(z=self.array, colorscale=colorscale, **kwargs) + fig.update_traces(showscale=show_colorbar) + if show_metrics: + for metric in self.metrics: + metric.plotly(fig) + if show: + fig.show() + return fig + def plot( self, ax: plt.Axes = None, diff --git a/pylinac/core/mtf.py b/pylinac/core/mtf.py index 341a614d..0b089166 100644 --- a/pylinac/core/mtf.py +++ b/pylinac/core/mtf.py @@ -6,10 +6,12 @@ import argue import numpy as np +import plotly.graph_objects as go from matplotlib import pyplot as plt from scipy.interpolate import interp1d from .contrast import michelson +from .plotly_utils import add_title from .roi import HighContrastDiskROI @@ -103,6 +105,38 @@ def from_high_contrast_diskset( minimums = [roi.min for roi in diskset] return cls(spacings, maximums, minimums) + def plotly( + self, + fig: go.Figure | None = None, + x_label: str = "Line pairs / mm", + y_label: str = "Relative MTF", + title: str = "Relative MTF", + name: str = "rMTF", + **kwargs, + ) -> go.Figure: + """Plot the Relative MTF. + + Parameters + ---------- + """ + fig = fig or go.Figure() + fig.add_scatter( + x=list(self.norm_mtfs.keys()), + y=list(self.norm_mtfs.values()), + mode="markers+lines", + name=name, + **kwargs, + ) + fig.update_layout( + xaxis_title=x_label, + yaxis_title=y_label, + ) + add_title(fig, title) + fig.update_layout( + showlegend=True, + ) + return fig + def plot( self, axis: plt.Axes | None = None, diff --git a/pylinac/core/plotly_utils.py b/pylinac/core/plotly_utils.py new file mode 100644 index 00000000..279f9d8e --- /dev/null +++ b/pylinac/core/plotly_utils.py @@ -0,0 +1,89 @@ +from typing import Sequence + +from plotly import graph_objects as go + + +def add_title(fig: go.Figure, title: str) -> None: + """Set the title of a plotly figure at the center of the image""" + fig.update_layout(title_text=title, title_x=0.5) + + +def set_axis_range(fig: go.Figure, x: Sequence[float], y: Sequence[float]) -> None: + """Set the axis range of a plotly figure. There's some bug in plotly that won't + correctly range the Y axis if autorange is already set. This works around that bug + and in one spot vs trying to remember on each call.""" + fig.update_layout(xaxis_range=x, yaxis_range=y, yaxis_autorange=False) + + +def add_vertical_line( + fig: go.Figure, + x: float, + color: str = "black", + width: int = 1, + opacity: float = 1, + name: str = "", +) -> None: + """Add a vertical line to a plotly figure.""" + # get the current data limits + # otherwise this can fall outside the image plot + d = None + for trace in fig.data: + if trace.type == "heatmap": + d = trace + break + if d: + fig.add_scatter( + x=[x, x], + y=[0, d.z.shape[0]], + mode="lines", + line=dict(color=color, width=width), + opacity=opacity, + name=name, + ) + + +def add_horizontal_line( + fig: go.Figure, + y: float, + color: str = "black", + width: int = 1, + opacity: float = 1, + name: str = "", +) -> None: + """Add a horizontal line to a plotly figure.""" + d = None + for trace in fig.data: + if trace.type == "heatmap": + d = trace + break + if d: + fig.add_shape( + dict( + type="line", + x0=0, + x1=d.z.shape[1], + y0=y, + y1=y, + xref="x", + yref="y", + line=dict(color=color, width=width), + opacity=opacity, + name=name, + ) + ) + else: + # it's a simple plot, just use paper reference + fig.add_shape( + dict( + type="line", + x0=0, + x1=1, + y0=y, + y1=y, + xref="paper", + yref="y", + line=dict(color=color, width=width), + opacity=opacity, + name=name, + ) + ) diff --git a/pylinac/core/profile.py b/pylinac/core/profile.py index 5b8325f9..e4ecf880 100644 --- a/pylinac/core/profile.py +++ b/pylinac/core/profile.py @@ -12,6 +12,7 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Circle as mpl_Circle +from plotly import graph_objects as go from scipy import ndimage, signal from scipy.interpolate import InterpolatedUnivariateSpline, UnivariateSpline, interp1d from scipy.ndimage import gaussian_filter1d, zoom @@ -32,7 +33,7 @@ from .gamma import gamma_1d, gamma_geometric from .geometry import Circle, Point from .hill import Hill -from .utilities import convert_to_enum +from .utilities import TemporaryAttribute, convert_to_enum # for Hill fits of 2D device data the # of points can be small. # This results in optimization warnings about the variance of the fit (the variance isn't of concern for us for that particular item) @@ -434,6 +435,26 @@ def resample_to( output_type = self.__class__ return output_type(values=target_y, x_values=target_x) + def plotly( + self, + fig: go.Figure | None = None, + show: bool = True, + show_field_edges: bool = True, + show_grid: bool = True, + show_center: bool = True, + mirror: Literal["beam", "geometry"] | None = None, + name: str = "Profile", + ) -> go.Figure: + """Plot the profile to a plotly figure.""" + + if fig is None: + fig = go.Figure() + fig.add_scatter(x=self.x_values, y=self.values, name=name) + + if show: + fig.show() + return fig + def plot( self, show: bool = True, @@ -802,7 +823,6 @@ def gamma( gamma_cap_value: float = 2, dose_threshold: float = 5, fill_value: float = np.nan, - centering: Centering | str = Centering.GEOMETRIC_CENTER, return_profiles: bool = False, ) -> np.ndarray | (np.ndarray, PhysicalProfileMixin, PhysicalProfileMixin): """Compute the gamma index between the profile and a reference profile. @@ -811,11 +831,6 @@ def gamma( ---------- reference_profile : ProfileBase The reference profile to compare against. - centering - The centering method of the profiles. If Manual, - no centering is done. It is assumed you have already performed any necessary centering. - If Geometric, will center both profiles geometrically. - If Beam, will center both profiles at the beam center. return_profiles : bool Whether to return the gamma index values or the gamma index values and the two profiles. The profiles are adjusted to be geometrically centered and thus are not the original profiles. @@ -832,19 +847,11 @@ def gamma( raise ValueError("The reference profile must also be a physical profile.") evaluation = copy.deepcopy(self) reference = copy.deepcopy(reference_profile) - # center the profiles - center = convert_to_enum(centering, Centering) - if center == Centering.GEOMETRIC_CENTER: - ref_x = reference.x_values - reference.geometric_center_idx - eval_x = evaluation.x_values - evaluation.geometric_center_idx - reference.x_values = ref_x - evaluation.x_values = eval_x - elif center == Centering.BEAM_CENTER: - ref_x = reference.x_values - reference.center_idx - eval_x = evaluation.x_values - evaluation.center_idx - reference.x_values = ref_x - evaluation.x_values = eval_x - # no action needed for manual + # center the profiles geometrically by shifting x-values to the mean + ref_x = reference.x_values - reference.geometric_center_idx + eval_x = evaluation.x_values - evaluation.geometric_center_idx + reference.x_values = ref_x + evaluation.x_values = eval_x gamma = gamma_geometric( reference=reference.values, @@ -870,7 +877,6 @@ def plot_gamma( gamma_cap_value: float = 2, dose_threshold: float = 5, fill_value: float = np.nan, - centering: Centering | str = Centering.GEOMETRIC_CENTER, axis: plt.Axes | None = None, show: bool = True, ) -> plt.Axes: @@ -891,7 +897,6 @@ def plot_gamma( dose_threshold=dose_threshold, fill_value=fill_value, return_profiles=True, - centering=centering, ) if axis is None: _, axis = plt.subplots() @@ -2255,6 +2260,24 @@ def roll(self, amount: int) -> None: self.x_locations = np.roll(self.x_locations, -amount) self.y_locations = np.roll(self.y_locations, -amount) + def plotly( + self, + fig: go.Figure, + color: str = "black", + fill: bool = False, + plot_peaks: bool = True, + ): + Circle.plotly(self, fig, color, fill) + if plot_peaks: + x_locs = [peak.x for peak in self.peaks] + y_locs = [peak.y for peak in self.peaks] + fig.add_scatter( + x=x_locs, + y=y_locs, + mode="markers", + marker=dict(size=10, color=color), + ) + def plot2axes( self, axes: plt.Axes = None, @@ -2383,6 +2406,27 @@ def _profile(self) -> np.array: profile /= self.num_profiles return profile + def plotly( + self, + fig: go.Figure, + color: str = "black", + fill: bool = False, + plot_peaks: bool = True, + ): + with TemporaryAttribute(self, "radius", self.radius * (1 + self.width_ratio)): + super().plotly(fig, color, fill) + with TemporaryAttribute(self, "radius", self.radius * (1 - self.width_ratio)): + super().plotly(fig, color, fill) + if plot_peaks: + x_locs = [peak.x for peak in self.peaks] + y_locs = [peak.y for peak in self.peaks] + fig.add_scatter( + x=x_locs, + y=y_locs, + mode="markers", + marker=dict(size=10, color=color), + ) + def plot2axes( self, axes: plt.Axes = None, diff --git a/pylinac/core/utilities.py b/pylinac/core/utilities.py index 6d85e47d..6a71b622 100644 --- a/pylinac/core/utilities.py +++ b/pylinac/core/utilities.py @@ -194,6 +194,22 @@ def is_iterable(object) -> bool: return isinstance(object, Iterable) +class TemporaryAttribute: + """Context manager to temporarily set a class attribute.""" + + def __init__(self, cls, attribute_name, temporary_value): + self.cls = cls + self.attribute_name = attribute_name + self.temporary_value = temporary_value + self.original_value = getattr(cls, attribute_name) + + def __enter__(self): + setattr(self.cls, self.attribute_name, self.temporary_value) + + def __exit__(self, exc_type, exc_value, traceback): + setattr(self.cls, self.attribute_name, self.original_value) + + class Structure: """A simple structure that assigns the arguments to the object.""" diff --git a/pylinac/ct.py b/pylinac/ct.py index 74b9488b..55646fdc 100644 --- a/pylinac/ct.py +++ b/pylinac/ct.py @@ -30,6 +30,7 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.axes import Axes +from plotly import graph_objects as go from py_linq import Enumerable from pydantic import BaseModel, Field from scipy import ndimage @@ -48,6 +49,7 @@ noise_power_spectrum_1d, noise_power_spectrum_2d, ) +from .core.plotly_utils import add_title, add_vertical_line from .core.profile import CollapsedCircleProfile, FWXMProfile from .core.roi import DiskROI, LowContrastDiskROI, RectangleROI from .core.utilities import QuaacDatum, QuaacMixin, ResultBase, ResultsDataMixin @@ -534,6 +536,12 @@ def plot_rois(self, axis: plt.Axes) -> None: for roi in self.background_rois.values(): roi.plot2axes(axis, edgecolor="blue") + def plotly_rois(self, fig: go.Figure) -> None: + for name, roi in self.rois.items(): + roi.plotly(fig, color=roi.plot_color, name=name) + for name, roi in self.background_rois.items(): + roi.plotly(fig, color="blue", name=f"{name} Background") + def plot(self, axis: plt.Axes): """Plot the image along with ROIs to an axis""" self.image.plot(ax=axis, show=False, vmin=self.window_min, vmax=self.window_max) @@ -542,6 +550,16 @@ def plot(self, axis: plt.Axes): axis.set_title(self.common_name) axis.axis("off") + def plotly(self, **kwargs) -> go.Figure: + """Plot the image along with the ROIs to a plotly figure.""" + fig = go.Figure() + self.image.plotly( + fig, show=False, zmin=self.window_min, zmax=self.window_max, **kwargs + ) + self.plotly_rois(fig) + add_title(fig, self.common_name) + return fig + @property def roi_vals_as_str(self) -> str: return ", ".join( @@ -766,6 +784,49 @@ def lcv(self) -> float: / (self.rois["LDPE"].std + self.rois["Poly"].std) ) + def plotly_linearity(self, plot_delta: bool = True) -> go.Figure: + fig = go.Figure() + nominal_x_values = [roi.nominal_val for roi in self.rois.values()] + if plot_delta: + values = [roi.value_diff for roi in self.rois.values()] + nominal_measurements = [0] * len(values) + ylabel = "HU Delta" + else: + values = [roi.pixel_value for roi in self.rois.values()] + nominal_measurements = nominal_x_values + ylabel = "Measured Values" + fig.add_scatter( + x=nominal_x_values, + y=values, + name="Measured values", + mode="markers", + marker=dict(color="green", size=10, symbol="cross", line=dict(width=1)), + ) + fig.add_scatter( + x=nominal_x_values, + y=nominal_measurements, + mode="lines", + name="Nominal Values", + marker_color="green", + ) + fig.add_scatter( + x=nominal_x_values, + y=np.array(nominal_measurements) + self.hu_tolerance, + mode="lines", + name="Upper Tolerance", + line=dict(dash="dash", color="red"), + ) + fig.add_scatter( + x=nominal_x_values, + y=np.array(nominal_measurements) - self.hu_tolerance, + mode="lines", + name="Lower Tolerance", + line=dict(dash="dash", color="red"), + ) + fig.update_layout(xaxis_title="Nominal Values", yaxis_title=ylabel) + add_title(fig, "HU Linearity") + return fig + def plot_linearity( self, axis: plt.Axes | None = None, plot_delta: bool = True ) -> tuple: @@ -809,6 +870,15 @@ def passed_hu(self) -> bool: """Boolean specifying whether all the ROIs passed within tolerance.""" return all(roi.passed for roi in self.rois.values()) + def plotly_rois(self, fig: go.Figure) -> None: + super().plotly_rois(fig) + # plot thickness ROIs + for name, roi in self.thickness_rois.items(): + roi.plotly(fig, color="blue", name=f"Ramp {name}") + # plot geometry lines + for name, line in self.lines.items(): + line.plotly(fig, color=line.pass_fail_color, name=f"Geometry {name}") + def plot_rois(self, axis: plt.Axes) -> None: """Plot the ROIs onto the image, as well as the background ROIs""" # plot HU linearity ROIs @@ -1068,6 +1138,12 @@ def plot(self, axis: plt.Axes): nps_roi.plot2axes(axis, edgecolor="green", linestyle="-.") super().plot(axis) + def plotly(self, **kwargs) -> go.Figure: + fig = super().plotly(**kwargs) + for name, nps_roi in self.nps_rois.items(): + nps_roi.plotly(fig, color="green", line_dash="dash", name=f"NPS {name}") + return fig + @property def overall_passed(self) -> bool: """Boolean specifying whether all the ROIs passed within tolerance.""" @@ -1268,6 +1344,12 @@ def radius2linepairs(self) -> float: """Radius from the phantom center to the line-pair region, corrected for pixel spacing.""" return self.radius2linepairs_mm / self.mm_per_pixel + def plotly_rois(self, fig: go.Figure) -> None: + self.circle_profile.plotly(fig, color="blue", plot_peaks=False) + fig.update_layout( + showlegend=False, + ) + def plot_rois(self, axis: plt.Axes) -> None: """Plot the circles where the profile was taken within.""" self.circle_profile.plot2axes(axis, edgecolor="blue", plot_peaks=False) @@ -1724,6 +1806,50 @@ def from_zip( obj.was_from_zip = True return obj + def plotly_analyzed_image( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + figs["CTP404"] = self.ctp404.plotly() + figs["HU Linearity"] = self.ctp404.plotly_linearity() + figs["Side View"] = self.plotly_side_view() + if self._has_module(CTP486): + figs["CTP486"] = self.ctp486.plotly() + if self._has_module(CTP528CP504): + figs["CTP528"] = self.ctp528.plotly() + figs["MTF"] = self.ctp528.mtf.plotly() + if self._has_module(CTP515): + figs["CTP515"] = self.ctp515.plotly() + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs) -> None: """Plot the images used in the calculation and summary data. @@ -2187,6 +2313,22 @@ def _zip_images(self) -> None: except Exception: pass + def plotly_side_view(self, offset: float = -10) -> go.Figure: + fig = go.Figure() + side_array = self.dicom_stack.side_view(axis=1) + add_title(fig, "Side View") + fig.add_heatmap(z=side_array, colorscale="gray", showscale=False) + + for module in self._detected_modules(): + add_vertical_line( + fig, + module.slice_num, + width=3, + color="blue", + name=module.common_name, + ) + return fig + def plot_side_view(self, axis: Axes) -> None: """Plot a view of the scan from the side with lines showing detected module positions""" side_array = self.dicom_stack.side_view(axis=1) diff --git a/pylinac/metrics/image.py b/pylinac/metrics/image.py index 066e967b..bf2b8bf4 100644 --- a/pylinac/metrics/image.py +++ b/pylinac/metrics/image.py @@ -7,6 +7,7 @@ import numpy as np from matplotlib import pyplot as plt +from plotly import graph_objects as go from skimage import measure, segmentation from skimage.measure._regionprops import RegionProperties @@ -72,6 +73,10 @@ def calculate(self) -> Any: """Calculate the metric. Can return anything""" pass + def plotly(self, fig: plt.Figure, **kwargs) -> None: + """Plot the metric using plotly.""" + pass + def plot(self, axis: plt.Axes, **kwargs) -> None: """Plot the metric""" pass @@ -163,6 +168,12 @@ def plot(self, axis: plt.Axes, **kwargs) -> None: kw = {**self.kwargs, **kwargs} self.roi.plot2axes(axis, edgecolor=edgecolor, **kw) + def plotly(self, fig: go.Figure, **kwargs) -> None: + """Plot the disk ROI.""" + edgecolor = kwargs.pop("edgecolor", self.edge_color) + kw = {**self.kwargs, **kwargs} + self.roi.plotly(fig, color=edgecolor, **kw) + class RectangleROIMetric(MetricBase): roi: RectangleROI @@ -252,6 +263,12 @@ def plot(self, axis: plt.Axes, **kwargs) -> None: kw = {**self.kwargs, **kwargs} self.roi.plot2axes(axis, edgecolor=edgecolor, **kw) + def plotly(self, fig: plt.Figure, **kwargs) -> None: + """Plot the disk ROI.""" + edgecolor = kwargs.pop("edgecolor", self.edge_color) + kw = {**self.kwargs, **kwargs} + self.roi.plotly(fig, color=edgecolor, **kw) + class GlobalSizedDiskLocator(MetricBase): name: str @@ -334,6 +351,29 @@ def calculate(self) -> list[Point]: self.x_boundaries.append(boundary_x) return self.points + def plotly( + self, + fig: go.Figure, + marker_color="red", + marker_symbol="circle-dot", + marker_size=3, + **kwargs, + ) -> None: + """Plot the BB centers""" + xs = [point.x for point in self.points] + ys = [point.y for point in self.points] + # for point in self.points: + fig.add_scatter( + x=xs, + y=ys, + mode="markers", + marker_color=marker_color, + marker_symbol=marker_symbol, + marker_size=marker_size, + name=self.name, + **kwargs, + ) + def plot( self, axis: plt.Axes, @@ -569,6 +609,31 @@ def calculate(self) -> list[RegionProperties]: self.points = points return regions + def plotly( + self, + fig: go.Figure, + show_boundaries: bool = True, + color: str = "red", + marker_size: float = 3, + opacity: float = 0.25, + **kwargs, + ) -> None: + """Plot the BB boundaries""" + if show_boundaries: + for boundary in self.boundaries: + boundary_y, boundary_x = np.nonzero(boundary) + fig.add_scatter( + x=boundary_x, + y=boundary_y, + mode="markers", + marker_color=color, + marker_symbol="square", + marker_size=marker_size, + marker_opacity=opacity, + name=f"{self.name} Boundary", + **kwargs, + ) + def plot( self, axis: plt.Axes, @@ -599,6 +664,37 @@ def calculate(self) -> list[Point]: super().calculate() return self.points + def plotly( + self, + fig: go.Figure, + show_boundaries: bool = True, + color: str = "red", + marker_size: float = 7, + opacity: float = 0.25, + **kwargs, + ) -> None: + """Plot the BB center""" + super().plotly( + fig, + show_boundaries=show_boundaries, + color=color, + marker_size=marker_size, + opacity=opacity, + **kwargs, + ) + for point in self.points: + fig.add_scatter( + x=[point.x], + y=[point.y], + mode="markers", + marker_color=color, + marker_symbol="circle-dot", + opacity=1, + marker_size=marker_size, + name=self.name, + **kwargs, + ) + def plot( self, axis: plt.Axes, diff --git a/pylinac/picketfence.py b/pylinac/picketfence.py index 9f0d823e..60d3e049 100644 --- a/pylinac/picketfence.py +++ b/pylinac/picketfence.py @@ -31,6 +31,7 @@ import numpy as np from matplotlib.axes import Axes from mpl_toolkits.axes_grid1 import make_axes_locatable +from plotly import graph_objs as go from py_linq import Enumerable from pydantic import Field @@ -38,6 +39,7 @@ from .core import image, pdf from .core.geometry import Line, Point, PointSerialized, Rectangle from .core.io import get_url, retrieve_demo_file +from .core.plotly_utils import add_title, add_vertical_line from .core.profile import FWXMProfilePhysical, MultiProfile from .core.utilities import ( QuaacDatum, @@ -891,6 +893,95 @@ def _leaves_in_view(self, analysis_width) -> list[tuple[int, int, int]]: if abs(center) < pixel_range / self.image.dpmm ] + def plotly_analyzed_image( + self, + mlc_peaks: bool = True, + overlay: bool = True, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed image, leaf error plots, and error histogram. + + Parameters + ---------- + mlc_peaks + Do/don't plot the detected MLC peak positions. + overlay + Do/don't plot the alpha overlay of the leaf status. + show + Whether to display the plot. Set to false for saving to a figure, etc. + show_colorbar + Whether to show the colorbar. + show_legend + Whether to show the legend. + kwargs + Keyword arguments to pass to the plotly image plot. + """ + if not self._is_analyzed: + raise RuntimeError("The image must be analyzed first. Use .analyze().") + # plot the image + figs = {} + fig = self.image.plotly( + show=False, show_legend=show_legend, show_colorbar=show_colorbar, **kwargs + ) + for idx, picket in enumerate(self.pickets): + picket.plotly_guardrails(fig=fig, picket=idx) + if mlc_peaks: + for mlc_meas in self.mlc_meas: + mlc_meas.plotly(fig=fig) + + if overlay: + for mlc_meas in self.mlc_meas: + mlc_meas.plotly_overlay(fig) + + # plot CAX + fig.add_scatter( + x=[self.image.center.x], + y=[self.image.center.y], + mode="markers", + marker_symbol="square-open-dot", + marker_color="red", + marker_size=10, + name="CAX", + ) + figs["Picket Fence"] = fig + + # plot histogram + errors = Enumerable(self.mlc_meas).select_many(lambda m: m.error).to_list() + histogram_fig = go.Figure() + histogram_fig.add_histogram( + x=errors, + ) + add_vertical_line(histogram_fig, self.tolerance, color="red", width=3) + add_vertical_line(histogram_fig, -self.tolerance, color="red", width=3) + + if self.action_tolerance is not None: + add_vertical_line( + histogram_fig, self.action_tolerance, color="magenta", width=3 + ) + add_vertical_line( + histogram_fig, -self.action_tolerance, color="magenta", width=3 + ) + add_title(histogram_fig, "Leaf error histogram") + min_x = min(min(errors), -self.tolerance * 1.1) + max_x = max(max(errors), self.tolerance * 1.1) + histogram_fig.update_layout( + xaxis_title="Error (mm)", + yaxis_title="Counts", + xaxis_range=[min_x, max_x], + ) + figs["Histogram"] = histogram_fig + + # leaf error plot + figs |= self._plotly_leaf_error_plots() + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image( self, guard_rails: bool = True, @@ -968,6 +1059,38 @@ def plot_analyzed_image( if show: plt.show() + def _plotly_leaf_error_plots(self) -> dict[str, go.Figure]: + error_items = np.asarray( + Enumerable(self.mlc_meas).select(lambda m: m.error).to_list() + ) + leaf_nums = np.asarray( + Enumerable(self.mlc_meas).select(lambda m: m.leaf_num).to_list(), dtype=int + ) + # 2 figs; one for absolute errors and another for signed errors + signed_fig, abs_fig = go.Figure(), go.Figure() + add_title(signed_fig, "Signed Leaf Error (mm) | Pair") + add_title(abs_fig, "Absolute Leaf Error (mm) | Pair") + for leaf_num in set(leaf_nums): + idxs = np.argwhere(leaf_nums == leaf_num) + errs = error_items[idxs].squeeze() + signed_fig.add_box( + y=errs, + x=[leaf_num] * len(idxs), + name=str(leaf_num), + fillcolor="blue", + line_color="black", + marker_color="black", + ) + abs_fig.add_box( + y=np.abs(errs), + x=[leaf_num] * len(idxs), + name=str(leaf_num), + fillcolor="blue", + line_color="black", + marker_color="black", + ) + return {"error signed": signed_fig, "error absolute": abs_fig} + def _add_leaf_error_subplot(self, ax: plt.Axes, barplot_kwargs: dict) -> None: """Add a bar subplot showing the leaf error.""" # get leaf positions, errors, standard deviation, and leaf numbers @@ -1409,6 +1532,15 @@ def full_leaf_nums(self) -> Sequence[str | int]: f"{RIGHT_MLC_PREFIX}{self.leaf_num}", ] + def plotly(self, fig: go.Figure): + """Plot the MLC measurement to a plotly figure.""" + for idx, line in enumerate(self.marker_lines): + line.plotly( + fig, + color=self.bg_color[idx], + name=f"Picket {self.picket_num} - Leaf {self.leaf_num}", + ) + def plot2axes(self, axes: plt.Axes, width: float | int = 1) -> None: """Plot the measurement to the axes.""" for idx, line in enumerate(self.marker_lines): @@ -1464,11 +1596,11 @@ def bg_color(self) -> Sequence[str]: colors = [] for idx, passed in enumerate(self.passed): if not passed: - colors.append("r") + colors.append("red") elif self._action_tolerance is not None: - colors.append("b" if self.passed_action[idx] else "m") + colors.append("blue" if self.passed_action[idx] else "magenta") else: - colors.append("b") + colors.append("blue") return colors @property @@ -1557,6 +1689,59 @@ def marker_lines(self) -> list[Line]: lines.append(line) return lines + def plotly_overlay(self, fig: go.Figure) -> None: + upper_point = ( + self.leaf_center_px - self.leaf_width_px / 2 * self._analysis_ratio + ) + lower_point = ( + self.leaf_center_px + self.leaf_width_px / 2 * self._analysis_ratio + ) + height = abs(upper_point - lower_point) * 0.8 + + for idx, (line, leaf) in enumerate(zip(self.marker_lines, self.full_leaf_nums)): + width = abs(self.error[idx]) * self._image.dpmm + + if self._orientation == Orientation.UP_DOWN: + y = line.center.y + x = self.position[idx] - (self.error[idx] * self._image.dpmm) / 2 + r = Rectangle(width, height, center=(x, y)) + # if any of the values are over tolerance, show another larger rectangle to draw the eye + if not self.passed[idx] or not self.passed_action[idx]: + re = Rectangle( + self._image_window.shape[1] * 0.2, height * 1.2, center=(x, y) + ) + re.plotly( + fig, + line_color=self.bg_color[idx], + fill=True, + opacity=0.3, + fillcolor=self.bg_color[idx], + showlegend=False, + ) + else: + x = line.center.x + y = self.position[idx] - (self.error[idx] * self._image.dpmm) / 2 + r = Rectangle(height, width, center=(x, y)) + if not self.passed[idx] or not self.passed_action[idx]: + re = Rectangle( + self._image_window.shape[1] * 0.2, height * 1.2, center=(x, y) + ) + re.plotly( + fig, + line_color=self.bg_color[idx], + fill=True, + opacity=0.3, + fillcolor=self.bg_color[idx], + showlegend=False, + ) + r.plotly( + fig, + fill=True, + opacity=1, + color=self.bg_color[idx], + showlegend=False, + ) + def plot_overlay2axes(self, axes: Axes, show_text: bool) -> None: """Create a rectangle overlay with the width of the error. I.e. it stretches from the picket fit to the MLC position. Gives more visual size to the""" # calculate height (based on leaf analysis ratio) @@ -1717,6 +1902,39 @@ def right_guard_separated(self): other_fit[-1] += self._nominal_gap * mag_factor / 2 * self.image.dpmm return [np.poly1d(r_fit), np.poly1d(other_fit)] + def plotly_guardrails(self, fig: go.Figure, picket: int) -> None: + """Plot guard rails to the axis.""" + if self.orientation == Orientation.UP_DOWN: + length = self.image.shape[0] + else: + length = self.image.shape[1] + x_data = np.arange(length) + left_y_data = self.left_guard_separated + right_y_data = self.right_guard_separated + for left, right in zip(left_y_data, right_y_data): + if self.orientation == Orientation.UP_DOWN: + fig.add_scatter( + mode="lines", + x=left(x_data), + y=x_data, + line_color="green", + name=f"Left Guard Rail - {picket}", + ) + fig.add_scatter( + mode="lines", + x=right(x_data), + y=x_data, + line_color="green", + name=f"Right Guard Rail - {picket}", + ) + else: + fig.add_scatter( + mode="lines", x=x_data, y=left(x_data), line_color="green" + ) + fig.add_scatter( + mode="lines", x=x_data, y=right(x_data), line_color="green" + ) + def add_guards_to_axes( self, axis: plt.Axes, idx: int, color: str = "g", show_text: bool = False ) -> None: diff --git a/pylinac/planar_imaging.py b/pylinac/planar_imaging.py index 9c7e2ffc..bcefc3b5 100644 --- a/pylinac/planar_imaging.py +++ b/pylinac/planar_imaging.py @@ -32,6 +32,8 @@ import matplotlib.pyplot as plt import numpy as np +from plotly import graph_objects as go +from plotly.subplots import make_subplots from py_linq import Enumerable from pydantic import Field from scipy.ndimage import median_filter @@ -39,12 +41,13 @@ from skimage.measure._regionprops import RegionProperties from . import Normalization -from .core import geometry, image, pdf, validators +from .core import image, pdf, validators from .core.contrast import Contrast from .core.decorators import lru_cache from .core.geometry import Circle, Point, Rectangle, Vector from .core.io import get_url, retrieve_demo_file from .core.mtf import MTF +from .core.plotly_utils import add_title from .core.profile import CollapsedCircleProfile, FWXMProfilePhysical from .core.roi import DiskROI, HighContrastDiskROI, LowContrastDiskROI, bbox_center from .core.utilities import QuaacDatum, QuaacMixin, ResultBase, ResultsDataMixin @@ -554,22 +557,10 @@ def _create_phantom_outline_object(self) -> tuple[Rectangle | Circle, dict]: outline_settings = list(self.phantom_outline_object.values())[0] settings = {} if outline_type == "Rectangle": - side_a = self.phantom_radius * outline_settings["width ratio"] - side_b = self.phantom_radius * outline_settings["height ratio"] - half_hyp = np.sqrt(side_a**2 + side_b**2) / 2 - internal_angle = np.rad2deg(np.arctan(side_b / side_a)) - new_x = self.phantom_center.x + half_hyp * ( - geometry.cos(internal_angle) - - geometry.cos(internal_angle + self.phantom_angle) - ) - new_y = self.phantom_center.y + half_hyp * ( - geometry.sin(internal_angle) - - geometry.sin(internal_angle + self.phantom_angle) - ) obj = Rectangle( width=self.phantom_radius * outline_settings["width ratio"], height=self.phantom_radius * outline_settings["height ratio"], - center=Point(new_x, new_y), + center=self.phantom_center, ) settings["angle"] = self.phantom_angle elif outline_type == "Circle": @@ -600,6 +591,96 @@ def percent_integral_uniformity( pius.append(percent_integral_uniformity(max=high, min=low)) return min(pius) + def plotly_analyzed_images( + self, + show: bool = True, + show_legend: bool = True, + show_colorbar: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + # plot the marked image + image_fig = self.image.plotly( + show=False, + title=f"{self.common_name} Phantom Analysis", + zmin=self.window_floor(), + zmax=self.window_ceiling(), + show_colorbar=show_colorbar, + **kwargs, + ) + figs["Image"] = image_fig + + # plot the outline image + if self.phantom_outline_object is not None: + outline_obj, settings = self._create_phantom_outline_object() + outline_obj.plotly(image_fig, color="blue", name="Outline", **settings) + # plot the low contrast background ROIs + if self.low_contrast_rois: + for idx, roi in enumerate(self.low_contrast_background_rois): + roi.plotly( + image_fig, + color="blue", + name=f"LCR{idx}", + showlegend=show_legend, + ) + # plot the low contrast ROIs + for idx, roi in enumerate(self.low_contrast_rois): + roi.plotly( + image_fig, + color=roi.plot_color, + name=f"LC{idx}", + showlegend=show_legend, + ) + # plot the high-contrast ROIs along w/ pass/fail coloration + if self.high_contrast_rois: + for idx, (roi, mtf) in enumerate( + zip(self.high_contrast_rois, self.mtf.norm_mtfs.values()) + ): + color = "blue" if mtf > self._high_contrast_threshold else "red" + roi.plotly( + image_fig, + color=color, + name=f"HC{idx}", + showlegend=show_legend, + ) + + # plot the low contrast value graph + if self.low_contrast_rois: + lowcon_fig = make_subplots(specs=[[{"secondary_y": True}]]) + figs["Low Contrast"] = lowcon_fig + self._plotly_lowcontrast_graph(lowcon_fig) + + # plot the high contrast MTF graph + if self.high_contrast_rois: + hicon_fig = go.Figure() + figs["High Contrast"] = hicon_fig + self._plotly_highcontrast_graph(hicon_fig) + + if show: + for f in figs.values(): + f.show() + return figs + def plot_analyzed_image( self, image: bool = True, @@ -700,6 +781,40 @@ def plot_analyzed_image( plt.show() return figs, names + def _plotly_lowcontrast_graph(self, fig: go.Figure): + """Plot the low contrast ROIs to an axes.""" + fig.add_scatter( + y=[roi.contrast for roi in self.low_contrast_rois], + line={ + "color": "magenta", + }, + name="Contrast", + ) + fig.add_scatter( + secondary_y=True, + y=[roi.contrast_to_noise for roi in self.low_contrast_rois], + line={ + "color": "blue", + }, + name="CNR", + ) + add_title(fig, "Low-frequency Contrast") + fig.update_xaxes( + title_text="ROI #", + showspikes=True, + ) + fig.update_yaxes( + title_text=f"Contrast ({self._low_contrast_method})", + secondary_y=False, + showspikes=True, + ) + fig.update_yaxes( + title_text=f"CNR ({self._low_contrast_method})", + secondary_y=True, + showgrid=False, + showspikes=True, + ) + def _plot_lowcontrast_graph(self, axes: plt.Axes): """Plot the low contrast ROIs to an axes.""" (line1,) = axes.plot( @@ -731,6 +846,24 @@ def _plot_highcontrast_graph(self, axes: plt.Axes): axes.set_xlabel("Line pairs / mm") axes.set_ylabel("relative MTF") + def _plotly_highcontrast_graph(self, fig: go.Figure) -> None: + """Plot the high contrast ROIs to an axes.""" + fig.add_scatter( + y=list(self.mtf.norm_mtfs.values()), + x=self.mtf.spacings, + line={ + "color": "black", + }, + name="rMTF", + ) + fig.update_layout( + xaxis_showspikes=True, + yaxis_showspikes=True, + xaxis_title="Line pairs / mm", + yaxis_title="relative MTF", + ) + add_title(fig, "High-frequency rMTF") + def results(self, as_list: bool = False) -> str | list[str]: """Return the results of the analysis. diff --git a/pylinac/quart.py b/pylinac/quart.py index 0b5b2ad6..7db9544e 100644 --- a/pylinac/quart.py +++ b/pylinac/quart.py @@ -9,6 +9,7 @@ import numpy as np import scipy.ndimage from matplotlib import pyplot as plt +from plotly import graph_objects as go from pydantic import BaseModel, Field from scipy.interpolate import interp1d @@ -349,6 +350,10 @@ def plot_rois(self, axis: plt.Axes): for name, profile_data in self.profiles.items(): profile_data["line"].plot2axes(axis, width=2, color="blue") + def plotly_rois(self, fig: go.Figure) -> None: + for name, profile_data in self.profiles.items(): + profile_data["line"].plotly(fig, line_width=2, color="blue", name=name) + def distances(self) -> dict[str, float]: """The measurements of the phantom size for the two lines in mm""" return {f"{name} mm": p["width (mm)"] for name, p in self.profiles.items()} @@ -436,6 +441,51 @@ def analyze( self, tolerance=3, offset=GEOMETRY_OFFSET_MM ) + def plotly_analyzed_images( + self, + show: bool = True, + show_legend: bool = True, + show_colorbar: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + figs[self.hu_module.common_name] = self.hu_module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + figs["HU Linearity plot"] = self.hu_module.plotly_linearity() + figs[self.uniformity_module.common_name] = self.uniformity_module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + figs[self.geometry_module.common_name] = self.geometry_module.plotly( + show_colorbar=show_colorbar, show_legend=show_legend, **kwargs + ) + figs["Side View"] = self.plotly_side_view(offset=-5) + + if show: + for fig in figs.values(): + fig.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs) -> None: """Plot the images used in the calculation and summary data. diff --git a/pylinac/starshot.py b/pylinac/starshot.py index 7d448421..9bb65e63 100644 --- a/pylinac/starshot.py +++ b/pylinac/starshot.py @@ -29,12 +29,14 @@ import argue import matplotlib.pyplot as plt import numpy as np +import plotly.graph_objects as go from pydantic import Field from scipy import optimize from .core import image, pdf from .core.geometry import Circle, Line, Point from .core.io import TemporaryZipDirectory, get_url, retrieve_demo_file +from .core.plotly_utils import set_axis_range from .core.profile import CollapsedCircleProfile, FWXMProfile from .core.utilities import QuaacDatum, QuaacMixin, ResultBase, ResultsDataMixin @@ -423,6 +425,69 @@ def _quaac_datapoints(self) -> dict[str, QuaacDatum]: ), } + def plotly_analyzed_images( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. Will plot a zoomed-out image and a zoomed-in image. + + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + for name, zoom in zip(("Image", "Wobble"), (False, True)): + fig = self.image.plotly( + show=False, + show_legend=show_legend, + show_colorbar=show_colorbar, + **kwargs, + ) + for line in self.lines: + line.plotly(fig, color="blue", showlegend=False) + self.wobble.plotly( + fig, + color="green", + name=f"Wobble Circle {self.wobble.diameter_mm:2.2f}mm", + hoverinfo="text", + hovertext=f"Wobble diameter: {self.wobble.diameter_mm:2.2f} mm", + ) + if zoom: + set_axis_range( + fig=fig, + x=[ + self.wobble.center.x - self.wobble.diameter, + self.wobble.center.x + self.wobble.diameter, + ], + y=[ + self.wobble.center.y - self.wobble.diameter, + self.wobble.center.y + self.wobble.diameter, + ], + ) + + figs[name] = fig + if show: + for f in figs.values(): + f.show() + return figs + def plot_analyzed_image(self, show: bool = True, **plt_kwargs: dict): """Draw the star lines, profile circle, and wobble circle on a matplotlib figure. diff --git a/pylinac/vmat.py b/pylinac/vmat.py index 218ea076..c0b8a10e 100644 --- a/pylinac/vmat.py +++ b/pylinac/vmat.py @@ -19,6 +19,7 @@ import matplotlib.pyplot as plt import numpy as np +from plotly import graph_objects as go from pydantic import BaseModel, ConfigDict, Field from . import Normalization @@ -413,6 +414,74 @@ def max_r_deviation(self) -> float: """Return the value of the maximum R_deviation segment.""" return np.max(np.abs(self.r_devs)) + def plotly_analyzed_images( + self, + show: bool = True, + show_colorbar: bool = True, + show_legend: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + Parameters + ---------- + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + + # images + fig_open = self.open_image.plotly( + show=False, + title="Open Image", + show_colorbar=show_colorbar, + show_legend=show_legend, + **kwargs, + ) + self._draw_plotly_segments(fig=fig_open) + fig_dmlc = self.dmlc_image.plotly( + show=False, + title="DMLC Image", + show_colorbar=show_colorbar, + show_legend=show_legend, + **kwargs, + ) + self._draw_plotly_segments(fig=fig_dmlc) + + # median profiles + dmlc_prof, open_prof = self._median_profiles(self.dmlc_image, self.open_image) + fig_profile = go.Figure() + dmlc_prof.plotly(fig_profile, name="DMLC", show=False) + open_prof.plotly(fig_profile, name="Open", show=False) + fig_profile.update_layout( + title={ + "text": "Median Profiles", + "x": 0.5, + }, + xaxis_title="Pixel", + yaxis_title="Normalized Response", + coloraxis_showscale=show_colorbar, + showlegend=show_legend, + ) + + if show: + fig_open.show() + fig_dmlc.show() + fig_profile.show() + + return {"Open": fig_open, "DMLC": fig_dmlc, "Profile": fig_profile} + def plot_analyzed_image( self, show: bool = True, show_text: bool = True, **plt_kwargs: dict ): @@ -512,6 +581,21 @@ def _plot_analyzed_subimage( if show: plt.show() + def _draw_plotly_segments(self, fig: go.Figure) -> None: + """Draw the segments onto a plotly figure. + + Parameters + ---------- + fig : go.Figure + The figure to draw the objects on. + """ + for segment, roi_name in zip(self.segments, self.roi_config.keys()): + segment.plotly( + fig, + color=segment.get_bg_color(), + name=f"{roi_name} ({segment.r_dev:2.2f}%)", + ) + def _draw_segments(self, axis: plt.Axes, show_text: bool): """Draw the segments onto a plot. diff --git a/pylinac/winston_lutz.py b/pylinac/winston_lutz.py index 445f2166..d6f30f47 100644 --- a/pylinac/winston_lutz.py +++ b/pylinac/winston_lutz.py @@ -37,6 +37,7 @@ import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d import art3d +from plotly import graph_objects as go from py_linq import Enumerable from pydantic import BaseModel, Field from scipy import ndimage, optimize @@ -59,6 +60,7 @@ ) from .core.image import DicomImageStack, is_image, tiff_to_dicom from .core.io import TemporaryZipDirectory, get_url, retrieve_demo_file +from .core.plotly_utils import add_horizontal_line, add_title, add_vertical_line from .core.scale import MachineScale, convert from .core.utilities import ( QuaacDatum, @@ -346,6 +348,21 @@ def measured_field_position(self) -> Point: # vectors and points are effectively the same thing here but we convert to a point for clarity return Point(x=vector.x, y=vector.y, z=vector.z) + def plotly_nominal(self, fig: go.Figure, color: str, **kwargs) -> None: + x, y, z = create_sphere_surface( + radius=self.bb_config.bb_size_mm / 2, center=self.nominal_bb_position + ) + fig.add_surface( + x=x, + y=y, + z=z, + name=f"Nominal BB - {self.bb_config.name}", + showscale=False, + colorscale=[[0, color], [1, color]], + showlegend=True, + **kwargs, + ) + def plot_nominal(self, axes: plt.Axes, color: str, **kwargs): """Plot the BB nominal position""" x, y, z = create_sphere_surface( @@ -353,6 +370,22 @@ def plot_nominal(self, axes: plt.Axes, color: str, **kwargs): ) axes.plot_surface(x, y, z, color=color, **kwargs) + def plotly_measured(self, fig: go.Figure, color: str, **kwargs): + """Plot the BB measured position""" + x, y, z = create_sphere_surface( + radius=self.bb_config.bb_size_mm / 2, center=self.measured_bb_position + ) + fig.add_surface( + x=x, + y=y, + z=z, + name=f"Measured BB - {self.bb_config.name}", + showscale=False, + colorscale=[[0, color], [1, color]], + showlegend=True, + **kwargs, + ) + def plot_measured(self, axes: plt.Axes, color: str, **kwargs): """Plot the BB measured position""" x, y, z = create_sphere_surface( @@ -741,6 +774,7 @@ def find_bb_centroids( radius_tolerance_mm=bb_tolerance_mm, invert=not low_density, detection_conditions=self.detection_conditions, + name="BB", ) ) return centers @@ -780,6 +814,73 @@ def epid_to_bb_distances(self) -> list[float]: match.bb_epid_distance_mm for match in self.arrangement_matches.values() ] + def plotly( + self, + fig: go.Figure | None = None, + show: bool = True, + zoomed: bool = True, + show_legend: bool = True, + show_colorbar: bool = True, + ) -> go.Figure: + fig = super().plotly( + fig=fig, show=show, show_metrics=True, show_colorbar=show_colorbar + ) + # show EPID center + add_vertical_line(fig, self.epid.x, color="blue", name="EPID Center") + add_horizontal_line(fig, self.epid.y, color="blue") + # show the field CAXs + for match in self.arrangement_matches.values(): + fig.add_scatter( + x=[match.field.x], + y=[match.field.y], + line_color="green", + name="Field Center", + mode="markers", + marker_size=8, + marker_symbol="square", + ) + fig.add_scatter( + x=[match.bb.x], + y=[match.bb.y], + line_color="cyan", + name="Detected BB", + mode="markers", + marker_size=10, + marker_symbol="circle", + ) + if zoomed: + # zoom to the BBs + min_x = ( + min([match.bb.x for match in self.arrangement_matches.values()]) + - 20 * self.dpmm + ) + min_y = ( + min([match.bb.y for match in self.arrangement_matches.values()]) + - 20 * self.dpmm + ) + max_x = ( + max([match.bb.x for match in self.arrangement_matches.values()]) + + 20 * self.dpmm + ) + max_y = ( + max([match.bb.y for match in self.arrangement_matches.values()]) + + 20 * self.dpmm + ) + fig.update_xaxes(range=[min_x, max_x]) + # bug in plotly; can't have autorange reversed and set this. + fig.update_yaxes(range=[max_y, min_y], autorange=None) + fig.update_layout( + xaxis_title=f"Gantry={self.gantry_angle:.0f}, Coll={self.collimator_angle:.0f}, Couch={self.couch_angle:.0f}", + yaxis_title=f"Max Nominal to BB: {max(self.field_to_bb_distances()):3.2f}mm", + ) + fig.update_layout( + showlegend=show_legend, + title_text="\n".join(wrap(Path(self.path).name, 30)), + ) + if show: + fig.show() + return fig + def plot( self, ax: plt.Axes | None = None, @@ -880,6 +981,10 @@ def _calculate_bb_tolerance(self, bb_diameter: float) -> int: x = (1.5, 30) return np.interp(bb_diameter, x, y) + def to_axes(self) -> str: + """Give just the axes values as a human-readable string""" + return f"Gantry={self.gantry_angle:.1f}, Coll={self.collimator_angle:.1f}, Couch={self.couch_angle:.1f}" + @property def variable_axis(self) -> Axis: """The axis that is varying. @@ -990,10 +1095,6 @@ def analyze( def __repr__(self): return f"WLImage(gantry={self.gantry_angle:.1f}, coll={self.collimator_angle:.1f}, couch={self.couch_angle:.1f})" - def to_axes(self) -> str: - """Give just the axes values as a human-readable string""" - return f"Gantry={self.gantry_angle:.1f}, Coll={self.collimator_angle:.1f}, Couch={self.couch_angle:.1f}" - @property def cax2bb_vector(self) -> Vector: """The vector in mm from the CAX to the BB.""" @@ -1595,6 +1696,171 @@ def cax2epid_distance(self, metric: str = "max") -> float: elif metric == "mean": return statistics.mean(distances) + def plotly_analyzed_images( + self, + zoomed: bool = True, + show_legend: bool = True, + show: bool = True, + show_colorbar: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed images in a Plotly figure. + + Parameters + ---------- + zoomed : bool + Whether to zoom in on the BBs of the 2D images. + show_legend : bool + Whether to show the legend on the plot. + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + for idx, wl_image in enumerate(self.images): + fig = wl_image.plotly( + show=False, + show_legend=show_legend, + zoomed=zoomed, + show_colorbar=show_colorbar, + **kwargs, + ) + # we add a enumerator in case there are multiple images with the same axis values + figs[f"{idx} - {wl_image.to_axes()}"] = fig + + # 3d iso visualization + iso_fig = go.Figure() + # origin lines + limit = ( + max( + np.abs( + ( + self.bb_shift_vector.x, + self.bb_shift_vector.y, + self.bb_shift_vector.z, + ) + ) + ) + + self._bb_diameter + ) + for x, y, z in ( + ((-limit, limit), (0, 0), (0, 0)), + ((0, 0), (-limit, limit), (0, 0)), + ((0, 0), (0, 0), (-limit, limit)), + ): + iso_fig.add_scatter3d( + mode="lines", x=x, y=y, z=z, name="Isocenter Axis", marker_color="blue" + ) + # isosphere + x, y, z = create_sphere_surface( + radius=self.cax2bb_distance("max"), + center=Point(), + ) + iso_fig.add_surface( + x=x, + y=y, + z=z, + opacity=0.2, + name="Isosphere", + showscale=False, + colorscale=[[0, "blue"], [1, "blue"]], + showlegend=True, + ) + # bb + x, y, z = create_sphere_surface( + radius=self._bb_diameter / 2, + center=Point( + self.bb.measured_bb_position.x, + self.bb.measured_bb_position.y, + self.bb.measured_bb_position.z, + ), + ) + iso_fig.add_surface( + x=x, + y=y, + z=z, + opacity=0.1, + name="BB", + showscale=False, + colorscale=[[0, "red"], [1, "red"]], + showlegend=True, + ) + # coll iso size + theta = np.linspace(0, 2 * np.pi, 100) + circle_y = self.collimator_iso_size / 2 * np.cos(theta) # Radius of the circle + circle_z = self.collimator_iso_size / 2 * np.sin(theta) # Radius of the circle + circle_x = np.zeros_like(theta) + limit # Fixed z-coordinate + iso_fig.add_scatter3d( + x=circle_x, + y=circle_y, + z=circle_z, + mode="lines", + line=dict(color="green", width=2), + name="Collimator axis isosize projection", + hovertext=f"Collimator isocenter size: {self.collimator_iso_size:.2f}mm", + hoverinfo="text", + ) + # gantry iso size + circle_x = self.gantry_iso_size / 2 * np.cos(theta) # Radius of the circle + circle_z = self.gantry_iso_size / 2 * np.sin(theta) # Radius of the circle + circle_y = np.zeros_like(theta) - limit # Fixed z-coordinate + iso_fig.add_scatter3d( + x=circle_x, + y=circle_y, + z=circle_z, + mode="lines", + line=dict(color="green", width=2), + name="Gantry axis isosize projection", + hoverinfo="text", + hovertext=f"Gantry isocenter size: {self.gantry_iso_size:.2f}mm", + ) + + # couch isosize + circle_x = self.couch_iso_size / 2 * np.cos(theta) # Radius of the circle + circle_y = self.couch_iso_size / 2 * np.sin(theta) # Radius of the circle + circle_z = np.zeros_like(theta) - limit # Fixed z-coordinate + iso_fig.add_scatter3d( + x=circle_x, + y=circle_y, + z=circle_z, + mode="lines", + line=dict(color="green", width=2), + name="Couch axis isosize projection", + hoverinfo="text", + hovertext=f"Couch isocenter size: {self.couch_iso_size:.2f}mm", + ) + + iso_fig.update_layout( + scene=dict( + xaxis_range=[-limit, limit], + yaxis_range=[-limit, limit], + zaxis_range=[-limit, limit], + aspectmode="cube", + xaxis_title="X (mm), Right (+)", + yaxis_title="Y (mm), In (+)", + zaxis_title="Z (mm), Up (+)", + ), + # set the camera so x axis is on the lower left; makes for more natural visualization + scene_camera_eye=dict(x=-1, y=1, z=1), + showlegend=show_legend, + ) + add_title(iso_fig, "3D Isocenter visualization") + figs["Isocenter Visualization"] = iso_fig + + if show: + for f in figs.values(): + f.show() + return figs + def _plot_deviation( self, axis: Axis, ax: plt.Axes | None = None, show: bool = True ) -> None: @@ -2553,6 +2819,109 @@ def couch_iso_size(self) -> float: def gantry_iso_size(self) -> float: raise NotImplementedError("Not yet implemented") + def plotly_analyzed_images( + self, + zoomed: bool = True, + show_legend: bool = True, + show: bool = True, + show_colorbar: bool = True, + **kwargs, + ) -> dict[str, go.Figure]: + """Plot the analyzed set of images to Plotly figures. + + + Parameters + ---------- + zoomed : bool + Whether to zoom in on the 2D image plots. + show : bool + Whether to show the plot. + show_colorbar : bool + Whether to show the colorbar on the plot. + show_legend : bool + Whether to show the legend on the plot. + kwargs + Additional keyword arguments to pass to the plot. + + Returns + ------- + dict + A dictionary of the Plotly figures where the key is the name of the + image and the value is the figure. + """ + figs = {} + for idx, wl_image in enumerate(self.images): + fig = wl_image.plotly( + show=False, + show_legend=show_legend, + zoomed=zoomed, + show_colorbar=show_colorbar, + **kwargs, + ) + # we add a enumerator in case there are multiple images with the same axis values + figs[f"{idx} - {wl_image.to_axes()}"] = fig + + x_lim = max( + max([np.abs(bb.nominal_bb_position.x) for bb in self.bbs]) * 1.3, 10 + ) + y_lim = max( + max([np.abs(bb.nominal_bb_position.y) for bb in self.bbs]) * 1.3, 10 + ) + z_lim = max( + max([np.abs(bb.nominal_bb_position.z) for bb in self.bbs]) * 1.3, 10 + ) + limit = max(x_lim, y_lim, z_lim) + # 3d iso visualization + iso_fig = go.Figure() + figs["Isocenter Visualization"] = iso_fig + for x, y, z in ( + ((-limit, limit), (0, 0), (0, 0)), + ((0, 0), (-limit, limit), (0, 0)), + ((0, 0), (0, 0), (-limit, limit)), + ): + iso_fig.add_scatter3d( + mode="lines", x=x, y=y, z=z, name="Isocenter Axis", marker_color="blue" + ) + # bbs + for bb in self.bbs: + bb.plotly_measured(iso_fig, color="cyan", opacity=0.6) + bb.plotly_nominal(iso_fig, color="green", opacity=0.6) + + # isosphere + x, y, z = create_sphere_surface( + radius=self.cax2bb_distance("max"), + center=Point(), + ) + iso_fig.add_surface( + x=x, + y=y, + z=z, + opacity=0.2, + name="Isosphere", + showscale=False, + colorscale=[[0, "blue"], [1, "blue"]], + showlegend=True, + ) + iso_fig.update_layout( + scene=dict( + xaxis_range=[-limit, limit], + yaxis_range=[-limit, limit], + zaxis_range=[-limit, limit], + aspectmode="cube", + xaxis_title="X (mm), Right (+)", + yaxis_title="Y (mm), In (+)", + zaxis_title="Z (mm), Up (+)", + ), + # set the camera so x axis is on the lower left; makes for more natural visualization + scene_camera_eye=dict(x=-1, y=1, z=1), + showlegend=show_legend, + ) + add_title(iso_fig, "3D Isocenter visualization") + if show: + for f in figs.values(): + f.show() + return figs + def plot_images( self, show: bool = True, zoomed: bool = True, legend: bool = True, **kwargs ) -> (list[plt.Figure], list[str]): diff --git a/pyproject.toml b/pyproject.toml index d7e67af8..85a4d32e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pydantic>=2.0", "eval-type-backport; python_version<='3.8'", "quaac", + "plotly>=5.0", ] [project.optional-dependencies] diff --git a/tests_basic/core/test_gamma.py b/tests_basic/core/test_gamma.py index 7c72b3bf..0bef5318 100644 --- a/tests_basic/core/test_gamma.py +++ b/tests_basic/core/test_gamma.py @@ -3,10 +3,9 @@ import numpy as np -from pylinac import Centering from pylinac.core.gamma import _compute_distance, gamma_1d, gamma_2d, gamma_geometric from pylinac.core.image import DicomImage -from pylinac.core.image_generator import AS1000Image, AS1200Image, PerfectFieldLayer +from pylinac.core.image_generator import AS1000Image, AS1200Image from pylinac.core.profile import FWXMProfile, FWXMProfilePhysical from tests_basic.core.test_profile import generate_open_field from tests_basic.utils import get_file_from_cloud_test_repo @@ -657,14 +656,9 @@ def test_different_epids(self): """This test the same profile but with different EPIDs (i.e. pixel size)""" # we offset the reference by 1% to ensure we have a realistic gamma value img1200 = generate_open_field( - field_size=(100, 100), - imager=AS1200Image, - alpha=0.99, - field=PerfectFieldLayer, - ) - img1000 = generate_open_field( - field_size=(100, 100), imager=AS1000Image, field=PerfectFieldLayer + field_size=(100, 100), imager=AS1200Image, alpha=0.99 ) + img1000 = generate_open_field(field_size=(100, 100), imager=AS1000Image) p1200 = img1200.image[640, :] p1000 = img1000.image[384, :] p1200_prof = FWXMProfilePhysical(values=p1200, dpmm=1 / img1200.pixel_size) @@ -674,54 +668,3 @@ def test_different_epids(self): ) # gamma is very low; just pixel noise from the image generator self.assertAlmostEqual(np.nanmean(gamma), 0.948, delta=0.01) - - def test_beam_centering_offset_profile(self): - # similar test as above, but profiles are offset and no dose difference - # we expect gamma to be very low - # note the 5mm offset below which would otherwise cause a large gamma range - img1200 = generate_open_field( - field_size=(100, 100), - imager=AS1200Image, - center=(0, 5), - field=PerfectFieldLayer, - ) - img1000 = generate_open_field( - field_size=(100, 100), imager=AS1000Image, field=PerfectFieldLayer - ) - p1200 = img1200.image[640, :] - p1000 = img1000.image[384, :] - p1200_prof = FWXMProfilePhysical(values=p1200, dpmm=1 / img1200.pixel_size) - p1000_prof = FWXMProfilePhysical(values=p1000, dpmm=1 / img1000.pixel_size) - gamma = p1000_prof.gamma( - reference_profile=p1200_prof, - dose_to_agreement=1, - gamma_cap_value=2, - centering=Centering.BEAM_CENTER, - ) - # gamma is very low; just pixel noise from the image generator - self.assertAlmostEqual(np.nanmean(gamma), 0.003, delta=0.01) - - def test_beam_centering_manual(self): - # 5mm offset but we don't correct for it so expect a high gamma - img1200 = generate_open_field( - field_size=(100, 100), - imager=AS1200Image, - center=(0, 5), - field=PerfectFieldLayer, - ) - img1000 = generate_open_field( - field_size=(100, 100), imager=AS1000Image, field=PerfectFieldLayer - ) - p1200 = img1200.image[640, :] - p1000 = img1000.image[384, :] - p1200_prof = FWXMProfilePhysical(values=p1200, dpmm=1 / img1200.pixel_size) - p1000_prof = FWXMProfilePhysical(values=p1000, dpmm=1 / img1000.pixel_size) - gamma = p1000_prof.gamma( - reference_profile=p1200_prof, - dose_to_agreement=1, - gamma_cap_value=2, - centering=Centering.MANUAL, - ) - # gamma is relatively high because of the 5mm offset - self.assertAlmostEqual(np.nanmean(gamma), 0.327, delta=0.01) - self.assertEqual(np.nanmax(gamma), 2.0)