Skip to content

Commit

Permalink
Merged in feature/RAM-3123_bb_pf (pull request #317)
Browse files Browse the repository at this point in the history
Feature/RAM-3123 bb pf

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Dec 19, 2023
2 parents 2f7a6a9 + e5bcbef commit 5cc3ee9
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 2 deletions.
7 changes: 5 additions & 2 deletions pylinac/core/metrics.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import warnings

from ..metrics.features import * # noqa:F403
from ..metrics.image import * # noqa:F403
from ..metrics.utils import * # noqa:F403

raise DeprecationWarning(
"This module has been moved to pylinac.metrics. Please import from there in the future."
warnings.warn(
"This module has been moved to pylinac.metrics. Please import from there in the future.",
DeprecationWarning,
)
46 changes: 46 additions & 0 deletions pylinac/picketfence.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .core import image, pdf
from .core.geometry import Line, Point, Rectangle
from .core.io import get_url, retrieve_demo_file
from .core.metrics import SizedDiskLocator
from .core.profile import FWXMProfilePhysical, MultiProfile
from .core.utilities import ResultBase, convert_to_enum
from .log_analyzer import load_log
Expand Down Expand Up @@ -143,8 +144,11 @@ class PFResult(ResultBase):
class PFDicomImage(image.LinacDicomImage):
"""A subclass of a DICOM image that checks for noise and inversion when instantiated. Can also adjust for EPID sag."""

_central_axis: Point | None #:

def __init__(self, path: str, **kwargs):
crop_mm = kwargs.pop("crop_mm", 3)
self._central_axis = kwargs.pop("central_axis", None)
super().__init__(path, **kwargs)
# crop the images so that Elekta images don't fail. See #168
crop_pixels = int(round(crop_mm * self.dpmm))
Expand Down Expand Up @@ -179,6 +183,16 @@ def adjust_for_sag(self, sag: int, orientation: str | Orientation) -> None:
direction = "y" if orient == Orientation.UP_DOWN else "x"
self.roll(direction, sag)

@property
def center(self) -> Point:
"""Override the central axis call in the event we passed it directly"""
if self._central_axis is not None:
cax = copy.copy(self._central_axis)
cax.y = 2 * (self.shape[0] // 2) - cax.y
return cax
else:
return super().center


class PicketFence:
"""A class used for analyzing EPID images where radiation strips have been formed by the
Expand All @@ -187,6 +201,9 @@ class PicketFence:
for any angle.
"""

_from_bb_setup: bool = False
_bb_image: image.LinacDicomImage | None = None

def __init__(
self,
filename: str | Path | BinaryIO,
Expand Down Expand Up @@ -298,6 +315,28 @@ def from_multiple_images(
stream.seek(0)
return cls(stream, **kwargs)

@classmethod
def from_bb_setup(
cls, *args, bb_image: str | Path | BinaryIO, bb_diameter: float, **kwargs
):
"""Construct a PicketFence instance using a BB setup image to find the CAX first.
The CAX of the PF image is then overridden w/ the BB location from the first image.
Thank the French for this."""
bb_image = image.load(bb_image)
cax = bb_image.compute(
metrics=SizedDiskLocator.from_center_physical(
expected_position_mm=(0, 0),
search_window_mm=(30 + bb_diameter, 30 + bb_diameter),
radius_mm=bb_diameter / 2,
radius_tolerance_mm=bb_diameter * 0.1 + 1,
)
)
instance = cls(*args, **kwargs, image_kwargs={"central_axis": cax})
instance._from_bb_setup = True
instance._bb_image = bb_image
return instance

@property
def passed(self) -> bool:
"""Boolean specifying if all MLC positions were within tolerance."""
Expand Down Expand Up @@ -512,6 +551,7 @@ def analyze(
fwxm: int = 50,
separate_leaves: bool = False,
nominal_gap_mm: float = 3,
central_axis: Point | None = None,
) -> None:
"""Analyze the picket fence image.
Expand Down Expand Up @@ -582,6 +622,9 @@ def analyze(
nominal_gap_mm
The expected gap of the pickets in mm. Only used when separate leaves is True. Due to the DLG and EPID
scattering, this value will have to be determined by you with a known good delivery.
central_axis
The central axis of the beam. If None (default), the CAX is automatically determined. This
is used for French regulations where the CAX is set to the BB location from a separate image.
"""
if action_tolerance is not None and tolerance < action_tolerance:
raise ValueError("Tolerance cannot be lower than the action tolerance")
Expand All @@ -590,6 +633,9 @@ def analyze(
self.leaf_analysis_width = leaf_analysis_width_ratio
self.separate_leaves = separate_leaves

if central_axis:
self.image._central_axis = central_axis

if invert:
self.image.invert()

Expand Down
35 changes: 35 additions & 0 deletions tests_basic/test_picketfence.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
from scipy import ndimage

from pylinac.core import image
from pylinac.core.image import DicomImage
from pylinac.core.image_generator import (
AS1200Image,
FilteredFieldLayer,
GaussianFilterLayer,
generate_picketfence,
)
from pylinac.core.io import TemporaryZipDirectory
from pylinac.picketfence import MLC, MLCArrangement, Orientation, PFResult, PicketFence
from tests_basic.core.test_profile_metrics import create_bb_image
from tests_basic.utils import (
CloudFileMixin,
FromDemoImageTesterMixin,
Expand Down Expand Up @@ -252,6 +260,33 @@ def test_failed_leaves_separate(self):
)


class TestBBBasedAnalysis(TestCase):
def test_bb_pf_combo(self):
wl = create_bb_image(field_size=(50, 50), bb_size=5, offset=(2, 2))
bb_img = DicomImage.from_dataset(wl)
bb_img.save("bb_setup.dcm")

pf_file = "separated_wide_gap_up_down.dcm"
generate_picketfence(
simulator=AS1200Image(sid=1500),
field_layer=FilteredFieldLayer,
# this applies a non-uniform intensity about the CAX, simulating the horn effect
file_out=pf_file,
final_layers=[
GaussianFilterLayer(sigma_mm=1),
],
pickets=5,
picket_spacing_mm=50,
picket_width_mm=20, # wide-ish gap
orientation=Orientation.UP_DOWN,
)

pf = PicketFence.from_bb_setup(pf_file, bb_image="bb_setup.dcm", bb_diameter=5)
pf.analyze(separate_leaves=False)
results = pf.results_data()
self.assertAlmostEqual(results.max_error_mm, 0.0, delta=0.005)


class TestPlottingSaving(TestCase):
@classmethod
def setUpClass(cls):
Expand Down

0 comments on commit 5cc3ee9

Please sign in to comment.