Skip to content

Commit

Permalink
Merged in feature/RAM-2605_contrib_quasar_orth (pull request jrkerns#348
Browse files Browse the repository at this point in the history
)

RAM-2605 Add quasar and jaw orthogonality contribs. Refactor sized locator.

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Feb 29, 2024
2 parents dbb0693 + d5217bc commit 4547351
Show file tree
Hide file tree
Showing 19 changed files with 583 additions and 155 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ Generate an AS1000 50x50mm, centered open field image at gantry 45:
# plot the generated image
plt.imshow(as1000.image)
Read More: `Image Generator <https://pylinac.readthedocs.io/en/latest/image_generator.html>`_.

TG-51
~~~~~
Expand Down
21 changes: 21 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Changelog
v 3.21.0
--------

Contrib
^^^^^^^

A new ``contrib`` module has been added to pylinac: :ref:`contrib`. This section is available as ``pylinac.contrib``.
The intent is for community-contributed modules and/or one-off analyses that are not part of the core
library but are still useful. So far, many RadMachine customers have asked for one-off analyses.
While I disagree with adding one-off analyses to the core library, I also don't want to let the
code be in secret for no good reason.

CT
^^

Expand All @@ -24,6 +33,18 @@ CT
* The ``power_spectrum`` property of the CTP486 module has been renamed to ``power_spectrum_2d``
and another property, ``power_spectrum_1d`` has been added.

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

* The ``SizedDiskRegion`` and ``SizedDiskLocator`` classes now have a ``min_number``, ``max_number``, and ``min_separation_<pixels|mm>`` parameters,
as the ``GlobalSizedDiskLocator`` class does. This allows the user to specify the minimum and maximum number of disks.
Previously, the ``SizedDisk<Region|Locator>`` classes would only find one disk.

.. warning::

This change also means that ``SizedDiskLocator`` and ``SizedDiskRegion``'s ``calculate`` method will now always return a list of Points or ROIs.
Previously, a single Point or ROI was returned. This change will break code that was expecting a single Point or ROI.

v 3.20.0
--------

Expand Down
87 changes: 87 additions & 0 deletions docs/source/contrib.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
.. _contrib:

========
One-Offs
========

Over time, many people have asked for analyses or
changes that are not part of the core library.
The philosophy of pylinac has always been to provide modules
that match widely-used phantoms and protocols. That hasn't
changed. However, there are many one-off analyses that
might be useful to a small subset of users. This has occurred
several times with RadMachine users. The code is useful
enough that it should be shared, but not useful enough to
be included in the core library. Thus, the ``contrib`` module.

.. warning::

This module might not be tested. It is not guaranteed to work. It may break in future versions.
I (James) cannot guarantee support for it. It does not have comprehensive documentation. It is provided as-is.


Quasar eQA
==========

Use Case
--------

The customer has a special light/rad phantom with disk jigs that are set at the edge of the light field corners.
The BBs are offset by 11 mm from the corners. There is also a set of BBs in the center of the field used for scaling.

The field sizes used by the clinic are 6x6 and 18x18cm. The customer wants to use the center BBs for scaling
and the corner BBs for the light field.

Example image:

.. image:: images/quasar.png
:width: 400
:align: center

Usage
-----

.. code-block:: python
from pylinac.contrib.quasar import QuasarLightRadScaling
path = r"C:\path\to\image.dcm"
q = QuasarLightRadScaling(path)
q.analyze()
q.plot_analyzed_image()
print(q.results())
.. autoclass:: pylinac.contrib.quasar.QuasarLightRadScaling
:members:


Jaw Orthogonality
=================

Use Case
--------

French sites desired a way to test the jaw orthogonality of their linacs. This uses a standard open field.

Usage
-----

.. code-block:: python
from pylinac.contrib.orthogonality import JawOrthogonality
p = r"C:\path\to\image.dcm"
j = JawOrthogonality(p)
j.analyze()
print(j.results())
j.plot_analyzed_image()
Which will produce an image like so:

.. image:: images/orthogonality.png
:width: 400
:align: center

.. autoclass:: pylinac.contrib.orthogonality.JawOrthogonality
:members:
Binary file added docs/source/images/orthogonality.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/source/images/quasar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

core_modules
image_generator
contrib

.. toctree::
:hidden:
Expand Down
1 change: 1 addition & 0 deletions docs/source/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Pylinac runs on a few philosophical principles:
* Using pylinac should require a minimal amount of code.
* The user should have to supply as little information as necessary to run an analysis.
* The underlying code of pylinac should be easy to understand.
* Analyses are limited to commercial phantoms or widely-accepted methodologies.
* Although evaluation against a reference or baseline may occur, it is not the
end-goal. As stated above, there are better places for containing reference information.
Pylinac's goal moving forward is to provide top-tier analysis and results. How those
Expand Down
42 changes: 25 additions & 17 deletions docs/source/topics/image_metrics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,31 @@ Use Cases
Tool Legend
-----------

+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Use Case | Constraint | Class |
+=========================================================+=====================================================+=====================================================================+
| Find the location of a BB in the image | The BB size and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskLocator` |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the ROI properties of a BB in the image | The BB size and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskRegion` |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the location of *N* BBs in the image | The BB size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedDiskLocator` |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the location of a square field in an image | The field size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedFieldLocator` |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the locations of *N* square fields in an image | The field size is not known | :class:`~pylinac.metrics.image.GlobalFieldLocator` |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the location of a circular field in an image | The field size and location are known approximately | :class:`~pylinac.metrics.image.SizedDiskLocator` (``invert=False``) |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
| Find the ROI properties of a circular field in an image | The field size and location are known approximately | :class:`~pylinac.metrics.image.SizedDiskRegion` (``invert=False``) |
+---------------------------------------------------------+-----------------------------------------------------+---------------------------------------------------------------------+
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Use Case | Constraint | Class |
+=========================================================+==================================================================+==============================================================================+
| Find the location of a single BB in the image | The BB size and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskLocator` |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the locations of *N* BBs in the image | The BB sizes are all similar and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskLocator` (``max_number=<n>``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the ROI properties of a single BB in the image | The BB size and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskRegion` |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the ROI properties of *N* BBs in the image | The BB size and location is known approximately | :class:`~pylinac.metrics.image.SizedDiskRegion` (``max_number=<n>``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the location of a single BB anywhere in the image | The BB size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedDiskLocator` (``max_number=1``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the locations of *N* BBs anywhere in the image | The BB size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedDiskLocator` (``max_number=<n>``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the location of a square field in an image | The field size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedFieldLocator` |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the locations of *N* square fields in an image | The field size is known approximately | :class:`~pylinac.metrics.image.GlobalSizedFieldLocator` (``max_number=<n>``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the locations of *N* square fields in an image | The field size is not known | :class:`~pylinac.metrics.image.GlobalFieldLocator` (``max_number=<n>``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the location of a circular field in an image | The field size and location are known approximately | :class:`~pylinac.metrics.image.SizedDiskLocator` (``invert=False``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+
| Find the ROI properties of a circular field in an image | The field size and location are known approximately | :class:`~pylinac.metrics.image.SizedDiskRegion` (``invert=False``) |
+---------------------------------------------------------+------------------------------------------------------------------+------------------------------------------------------------------------------+

Basic Usage
-----------
Expand Down
Empty file added pylinac/contrib/__init__.py
Empty file.
104 changes: 104 additions & 0 deletions pylinac/contrib/orthogonality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from skimage.feature import canny
from skimage.transform import hough_line, hough_line_peaks

from pylinac.core.array_utils import stretch
from pylinac.core.image import load


class JawOrthogonality:
"""Determine the orthogonality of the jaws of a radiation field. Assumes a square field at a cardinal angle.
Parameters
----------
path : str
The path to the image.
"""

line_angles: dict[str, dict[str, float]]
result: dict[str, float]

def __init__(self, path: str | Path):
self.image = load(path)

def analyze(self):
"""Analyze the image for jaw orthogonality."""
edge_image = stretch(self.image.array)
edge_image = canny(edge_image)

# Classic straight-line Hough transform
# Sets a precision of 0.05 degree.
tested_angles = np.linspace(-np.pi / 2, np.pi / 2, num=360 * 10, endpoint=False)
h, theta, d = hough_line(edge_image, theta=tested_angles)

hspace, angles, dists = hough_line_peaks(h, theta, d)
sorted_angles_idx = np.argsort(np.abs(angles))
sorted_angles = angles[sorted_angles_idx]
sorted_dists = dists[sorted_angles_idx]
# we now have the horizontal lines in the first two indices
# and the vertical in the last two
# but we don't know which one is top/bottom or left/right
# so we need to sort them
# we can do this by sorting the distances; the lower distance is will be the top/left
# and the higher distance will be the bottom/right
line_angles = {}
if sorted_dists[0] < sorted_dists[1]:
line_angles["left"] = {"angle": sorted_angles[0], "dist": sorted_dists[0]}
line_angles["right"] = {"angle": sorted_angles[1], "dist": sorted_dists[1]}
else:
line_angles["left"] = {"angle": sorted_angles[1], "dist": sorted_dists[1]}
line_angles["right"] = {"angle": sorted_angles[0], "dist": sorted_dists[0]}
if sorted_dists[2] < sorted_dists[3]:
line_angles["bottom"] = {"angle": sorted_angles[2], "dist": sorted_dists[2]}
line_angles["top"] = {"angle": sorted_angles[3], "dist": sorted_dists[3]}
else:
line_angles["bottom"] = {"angle": sorted_angles[3], "dist": sorted_dists[3]}
line_angles["top"] = {"angle": sorted_angles[2], "dist": sorted_dists[2]}

top_left_angle = np.abs(
np.rad2deg(line_angles["left"]["angle"] - line_angles["top"]["angle"])
)
top_right_angle = np.abs(
np.rad2deg(line_angles["right"]["angle"] - line_angles["top"]["angle"])
)
bottom_left_angle = np.abs(
np.rad2deg(line_angles["left"]["angle"] - line_angles["bottom"]["angle"])
)
bottom_right_angle = np.abs(
np.rad2deg(line_angles["right"]["angle"] - line_angles["bottom"]["angle"])
)

result = {
"top_left": top_left_angle,
"top_right": top_right_angle,
"bottom_left": bottom_left_angle,
"bottom_right": bottom_right_angle,
}
self.line_angles = line_angles
self.result = result

def results(self) -> dict[str, float]:
"""Return a dict of the results. Keys are 'top_left', 'top_right', 'bottom_left', 'bottom_right'."""
return self.result

def plot_analyzed_image(self, show: bool = True):
"""Plot the image with the lines drawn. The lines are the detected jaw edges."""
colors = ["r", "b", "c", "m"]
fig, axes = plt.subplots()
for idx, (key, data) in enumerate(self.line_angles.items()):
(x0, y0) = data["dist"] * np.array(
[np.cos(data["angle"]), np.sin(data["angle"])]
)
axes.axline(
(x0, y0),
slope=np.tan(data["angle"] + np.pi / 2),
label=key,
color=colors[idx],
)
axes.set_title("Jaw Orthogonality")
axes.set_axis_off()
axes.legend()
self.image.plot(ax=axes, show=show)
66 changes: 66 additions & 0 deletions pylinac/contrib/quasar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from .. import StandardImagingFC2
from ..core.geometry import Point
from ..metrics.image import SizedDiskLocator


class QuasarLightRadScaling(StandardImagingFC2):
"""A light/rad and also scaling analysis for the Quasar phantom. The user
also uses custom edge blocks with offset BBs to detect the light corners.
It's a mix of scaling validation and light/rad."""

common_name = "Quasar Light/Rad Scaling"
bb_sampling_box_size_mm = 10
bb_size_mm = 5
field_strip_width_mm = 20
light_rad_bb_offset_mm = 11
scaling_centers: list[Point]

def analyze(
self, invert: bool = False, fwxm: int = 50, bb_edge_threshold_mm: float = 10
) -> None:
"""Analyze the image for the light/rad and scaling"""
super().analyze(
invert=invert, fwxm=fwxm, bb_edge_threshold_mm=bb_edge_threshold_mm
)
self.scaling_centers = self._detect_scaling_centers()

def _determine_bb_set(self, fwxm: int) -> dict:
"""We determine the BB set to use for the CAX (the light/rad part is separate).
We do this by first finding the field edges and then offsetting inward by 10 mm.
"""
fs_y = self.field_width_y / 2
fs_x = self.field_width_x / 2
positions_offsets = {
"TL": (
-fs_x + self.light_rad_bb_offset_mm,
-fs_y + self.light_rad_bb_offset_mm,
),
"BL": (
-fs_x + self.light_rad_bb_offset_mm,
fs_y - self.light_rad_bb_offset_mm,
),
"TR": (
fs_x - self.light_rad_bb_offset_mm,
fs_y - self.light_rad_bb_offset_mm,
),
"BR": (
fs_x - self.light_rad_bb_offset_mm,
-fs_y + self.light_rad_bb_offset_mm,
),
}
return positions_offsets

def _detect_scaling_centers(self) -> list[Point]:
"""Sample a 10x10mm square about each BB to detect it. Adjustable using self.bb_sampling_box_size_mm"""
scaling_centers = self.image.compute(
SizedDiskLocator.from_center_physical(
expected_position_mm=Point(0, 0),
search_window_mm=(35, 35),
radius_mm=self.bb_size_mm / 2,
radius_tolerance_mm=self.bb_size_mm / 2,
min_number=5,
max_number=5,
min_separation_mm=4,
)
)
return scaling_centers
Loading

0 comments on commit 4547351

Please sign in to comment.