diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f66a3802..87acf2e3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -19,6 +19,11 @@ Image Metrics * There was a memory bug when computing metrics. This shouldn't affect one-off computations, but could affect long-running processes. This has been fixed. +Starshot +^^^^^^^^ + +* :bdg-success:`Feature` Angles of the spokes are now reported via the ``angles`` attribute of ``results_data``. See :ref:`interpreting-starshot-results`. + v 3.29.0 -------- diff --git a/docs/source/starshot_docs.rst b/docs/source/starshot_docs.rst index 19c44882..6a4e6059 100644 --- a/docs/source/starshot_docs.rst +++ b/docs/source/starshot_docs.rst @@ -212,6 +212,8 @@ section. * ``circle_radius_mm`` -- The radius of the minimum circle that touches all the star lines in mm. * ``circle_diameter_mm`` -- The diameter of the minimum circle that touches all the star lines in mm. * ``circle_center_x_y`` -- The center position of the minimum circle in pixels. +* ``angles`` -- The angles of the spokes in degrees. 0 is pointing straight up. +90 is to the right; -90 is to the left. + Angles are always between +/-90. * ``passed`` -- Whether the analysis passed or failed. Troubleshooting diff --git a/pylinac/starshot.py b/pylinac/starshot.py index 69fb0578..183bd9e5 100644 --- a/pylinac/starshot.py +++ b/pylinac/starshot.py @@ -23,6 +23,7 @@ import copy import io +import math import webbrowser from pathlib import Path from typing import BinaryIO @@ -66,6 +67,10 @@ class StarshotResults(ResultBase): description="The center position of the minimum circle in pixels.", title="Circle center pixel (X, Y)", ) + angles: list[float] = Field( + description="The angles of the radiation lines in degrees. The angles are relative to the x-axis and range from +/- 90 degrees.", + title="Radiation line angles (degrees)", + ) passed: bool = Field(description="Whether the analysis passed or failed.") @@ -94,6 +99,8 @@ class Starshot(ResultsDataMixin[StarshotResults], QuaacMixin): >>> mystar.plot_analyzed_image() """ + angles: list[float] + def __init__(self, filepath: str | BinaryIO, **kwargs): """ Parameters @@ -284,6 +291,7 @@ def analyze( self._get_reasonable_wobble( start_point, fwhm, min_peak_height, radius, recursive, local_max ) + self.angles = calculate_angles(self.lines) def _get_reasonable_wobble( self, start_point, fwhm, min_peak_height, radius, recursive, local_max @@ -421,6 +429,7 @@ def _generate_results_data(self) -> StarshotResults: circle_diameter_mm=self.wobble.radius_mm * 2, circle_radius_mm=self.wobble.radius_mm, circle_center_x_y=(self.wobble.center.x, self.wobble.center.y), + angles=self.angles, passed=self.passed, ) @@ -471,8 +480,13 @@ def plotly_analyzed_images( show_colorbar=show_colorbar, **kwargs, ) - for line in self.lines: - line.plotly(fig, color="blue", showlegend=False) + for idx, line in enumerate(self.lines): + line.plotly( + fig, + color="blue", + showlegend=show_legend, + name=f"Line {idx} ({self.angles[idx]:2.2f}°)", + ) self.wobble.plotly( fig, line_color="green", @@ -799,3 +813,23 @@ def get_peak_height(): def get_radius(): yield from np.linspace(0.95, 0.1, 10) + + +def calculate_angles(lines: list[Line]) -> list[float]: + """Calculate the angles of the starshot spokes. What makes this + somewhat annoying is that the zero-angle is defined as pointing up (vs right for a unit angle) + and that we display the image with the y-axis increasing downward (vs upward).""" + angles = [] + for line in lines: + try: + phi_rad = math.atan(line.m) + phi_deg = math.degrees(phi_rad) - 90 + # Normalize the angle to be within (-90, +90) degrees + if phi_deg > 90: + phi_deg -= 180 + elif phi_deg <= -90: + phi_deg += 180 + except ZeroDivisionError: + phi_deg = 90 + angles.append(phi_deg) + return angles diff --git a/tests_basic/test_starshot.py b/tests_basic/test_starshot.py index cac974ec..61f86ee3 100644 --- a/tests_basic/test_starshot.py +++ b/tests_basic/test_starshot.py @@ -10,8 +10,8 @@ from parameterized import parameterized from pylinac import Starshot -from pylinac.core.geometry import Point -from pylinac.starshot import StarshotResults +from pylinac.core.geometry import Line, Point +from pylinac.starshot import StarshotResults, calculate_angles from tests_basic.core.test_utilities import QuaacTestBase, ResultsDataBase from tests_basic.utils import ( CloudFileMixin, @@ -78,6 +78,22 @@ def test_range_of_pixel_values(self, max_val: float): self.assertLessEqual(star.wobble.diameter_mm, 0.35) self.assertTrue(star.passed) + @parameterized.expand( + [ + ((0, 0), (1, 1), -45), + ((0, 0), (-1, -1), -45), + ((0, 0), (1, 0), 90), + ((0, 0), (-1, 0), 90), + ((0, 0), (0, 1), 0), + ((0, 0), (0, -1), 0), + ((0, 0), (1, -1), 45), + ((0, 0), (1, -0.5), 26.56), + ] + ) + def test_calculate_angle(self, point1, point2, angle): + angle = calculate_angles([Line(Point(*point1), Point(*point2))])[0] + self.assertAlmostEqual(angle, angle, places=2) + class TestPlottingSaving(TestCase): @classmethod