forked from jrkerns/pylinac
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merged in feature/RAM-2605_contrib_quasar_orth (pull request jrkerns#348
) RAM-2605 Add quasar and jaw orthogonality contribs. Refactor sized locator. Approved-by: Randy Taylor
- Loading branch information
Showing
19 changed files
with
583 additions
and
155 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,6 +38,7 @@ | |
|
||
core_modules | ||
image_generator | ||
contrib | ||
|
||
.. toctree:: | ||
:hidden: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.