Starshot

Overview

The Starshot module analyses a starshot image made of radiation spokes, whether gantry, collimator, MLC or couch. It is based on ideas from Depuydt et al and Gonzalez et al.

Features:

  • Analyze scanned film images, single EPID images, or a set of EPID images - Any image that you can load in can be analyzed, including 1 or a set of EPID DICOM images and films that have been digitally scanned.
  • Any image size - Have machines with different EPIDs? Scanned your film at different resolutions? No problem.
  • Dose/OD can be inverted - Whether your device/image views dose as an increase in value or a decrease, pylinac will detect it and invert if necessary.
  • Automatic noise detection & correction - Sometimes there’s dirt on the scanned film; sometimes there’s a dead pixel on the EPID. Pylinac will detect these spurious noise signals and can avoid or account for them.
  • Accurate, FWHM star line detection - Pylinac uses not simply the maximum value to find the center of a star line, but analyzes the entire star profile to determine the center of the FWHM, ensuring small noise or maximum value bias is avoided.
  • Adaptive searching - If you passed pylinac a set of parameters and a good result wasn’t found, pylinac can recover and do an adaptive search by adjusting parameters to find a “reasonable” wobble.

Running the Demo

To run the Starshot demo, create a script or start an interpreter and input:

from pylinac import Starshot

Starshot.run_demo()

(Source code, png, hires.png, pdf)

_images/starshot_docs-1.png

Results will be printed to the console and a matplotlib figure showing the analyzed starshot image will pop up:

Result: PASS

The minimum circle that touches all the star lines has a diameter of 0.381 mm.

The center of the minimum circle is at 1270.0, 1437.2

Image Acquisition

To capture starshot images, film is often used, but a sequence of EPID images can also work for collimator measurements. Pylinac can automatically superimpose the images. See the literature mentioned in the Overview for more info on acquisition.

Typical Use

The Starshot analysis can be run first by importing the Starshot class:

from pylinac import Starshot

A typical analysis sequence looks like so:

  • Load image(s) – Loading film or superimposed EPID DICOM images can be done by passing the file path or by using a UI to find and get the file. The code might look like any of the following:

    star_img = "C:/QA Folder/gantry_starshot.tif"
    mystar = Starshot(star_img)
    

    Multiple images can be easily superimposed and used; e.g. collimator shots at various angles:

    star_imgs = ['path/star0.tif', 'path/star45.tif', 'path/star90.tif']
    mystar = Starshot.from_multiple_images(star_imgs)
    
  • Analyze the image – After loading the image, all that needs to be done is analyze the image. You may optionally pass in some settings:

    mystar.analyze(radius=0.5, tolerance=0.8) # see API docs for more parameter info
    
  • View the results – Starshot can print out the summary of results to the console as well as draw a matplotlib image to show the detected radiation lines and wobble circle:

    # print results to the console
    print(mystar.results())
    # view analyzed image
    mystar.plot_analyzed_image()
    

    Additionally, the data can be accessed through a convenient StarshotResults class which comes in useful when using pylinac through an API or for passing data to other scripts/routines.

    # return a dataclass with introspection
    data = mystar.results_data()
    data.tolerance_mm
    data.passed
    ...
    
    # return as a dict
    data_dict = mystart.results_data(as_dict=True)
    data_dict['passed']
    ...
    

    Each subplot can be plotted independently as well:

    # just the wobble plot
    mystar.plot_analyzed_subimage('wobble')
    # just the zoomed-out plot
    mystar.plot_analyzed_subimage('whole')
    

    Saving the images is also just as easy:

    mystar.save_analyzed_image('mystar.png')
    

    You may also save to PDF:

    mystar.publish_pdf('mystar.pdf')
    

Algorithm

Allowances

  • The image can be either inversion (radiation is darker or brighter).
  • The image can be any size.
  • The image can be DICOM (from an EPID) or most image formats (scanned film).
  • If multiple images are used, they must all be the same size.

Restrictions

Warning

Analysis can fail or give unreliable results if any Restriction is violated.

  • The image must have at least 6 spokes (3 angles).
  • The center of the “star” must be in the central 1/3 of the image.
  • The radiation spokes must extend to both sides of the center. I.e. the spokes must not end at the center of the circle.

Pre-Analysis

  • Check for image noise – The image is checked for unreasonable noise by comparing the min and max to the 1/99th percentile pixel values respectively. If there is a large difference then there is likely an artifact and a median filter is applied until the min/max and 1/99th percentiles are similar.
  • Check image inversion – The image is checked for proper inversion using histogram analysis.
  • Set algorithm starting point – Unless the user has manually set the pixel location of the start point, it is automatically found by summing the image along each axis and finding the center of the full-width, 80%-max of each sum. The maximum value point is also located. Of the two points, the one closest to the center of the image is chosen as the starting point.

Analysis

  • Extract circle profile – A circular profile is extracted from the image centered around the starting point and at the radius given.
  • Find spokes – The circle profile is analyzed for peaks. Optionally, the profile is reanalyzed to find the center of the FWHM. An even number of spokes must be found (1 for each side; e.g. 3 collimator angles should produce 6 spokes, one for each side of the CAX).
  • Match peaks – Peaks are matched to their counterparts opposite the CAX to compose a line using a simple peak number offset.
  • Find wobble – Starting at the initial starting point, a Nelder-Mead gradient method is utilized to find the point of minimum distance to all lines. If recursive is set to True and a “reasonable” wobble (<2mm) is not found using the passes settings, the peak height and radius are iterated until a reasonable wobble is found.

Post-Analysis

  • Check if passed – Once the wobble is calculated, it is tested against the tolerance given, and passes if below the tolerance. If the image carried a pixel/mm conversion ratio, the tolerance and result are in mm, otherwise they will be in pixels.

Troubleshooting

First, check the general Troubleshooting section, especially if an image won’t load. Specific to the starshot analysis, there are a few things you can do.

  • Set recursive to True - This easy step in analyze() allows pylinac to search for a reasonable wobble even if the conditions you passed don’t for some reason give one.
  • Make sure the center of the star is in the central 1/3 of the image - Otherwise, pylinac won’t find it.
  • Make sure there aren’t egregious artifacts - Pin pricks can cause wild pixel values; crop them out if possible.
  • Set `invert` to True - While right most of the time, it’s possible the inversion checker got it wrong. This would look like peak locations in the “valley” regions of the image. If so, pass invert=True to the analyze method.

Benchmarking the Algorithm

With the image generator module we can create test images to test the starshot algorithm on known results. This is useful to isolate what is or isn’t working if the algorithm doesn’t work on a given image and when commissioning pylinac.

Perfect shot

Note

Due to the rounding of pixel positions of the star lines an absolutely perfect (0.0000mm wobble) is not achievable. The uncertainty of the algorithm is ~0.05mm.

Let’s create a perfect irradiation of a starshot pattern:

from scipy import ndimage

import pylinac
from pylinac.core.image_generator import GaussianFilterLayer, FilteredFieldLayer, AS1200Image, RandomNoiseLayer


star_path = 'perfect_starshot.dcm'
as1200 = AS1200Image()
for _ in range(6):
    as1200.add_layer(FilteredFieldLayer((270, 5), alpha=0.5))
    as1200.image = ndimage.rotate(as1200.image, 30, reshape=False, mode='nearest')
as1200.add_layer(GaussianFilterLayer(sigma_mm=3))
as1200.generate_dicom(file_out_name=star_path)

# analyze it
star = pylinac.Starshot(star_path)
star.analyze()
print(star.results())
star.plot_analyzed_image()

(Source code, png, hires.png, pdf)

_images/starshot_docs-2.png

with an output of:

Result: PASS

The minimum circle that touches all the star lines has a diameter of 0.045 mm.

The center of the minimum circle is at 639.5, 639.5

Note that there is still an identified wobble of ~0.045mm due to pixel position rounding of the generated image star lines. The center of the star is dead on at 639.5 (AS1200 image of shape 1278 and going to the middle of the pixel).

We can also evaluate the effect of changing the radius:

from scipy import ndimage

import pylinac
from pylinac.core.image_generator import GaussianFilterLayer, FilteredFieldLayer, AS1200Image, RandomNoiseLayer


star_path = 'perfect_starshot.dcm'
as1200 = AS1200Image()
for _ in range(6):
    as1200.add_layer(FilteredFieldLayer((270, 5), alpha=0.5))
    as1200.image = ndimage.rotate(as1200.image, 30, reshape=False, mode='nearest')
as1200.add_layer(GaussianFilterLayer(sigma_mm=3))
as1200.generate_dicom(file_out_name=star_path)

# analyze it
star = pylinac.Starshot(star_path)
star.analyze(radius=0.6)  # radius changed
print(star.results())
star.plot_analyzed_image()

(Source code, png, hires.png, pdf)

_images/starshot_docs-3.png

which results in:

Result: PASS

The minimum circle that touches all the star lines has a diameter of 0.036 mm.

The center of the minimum circle is at 639.5, 639.5

The center hasn’t moved but we do have a diameter of ~0.03mm now. Again, this is a limitation of both the algorithm and image generation.

Offset

We can also generate an offset starshot:

Note

This image is completely generated and depending on the angle and number of spokes, this result may change due to the fragility of rotating the image.

from scipy import ndimage

import pylinac
from pylinac.core.image_generator import GaussianFilterLayer, FilteredFieldLayer, AS1200Image, RandomNoiseLayer


star_path = 'offset_starshot.dcm'
as1200 = AS1200Image()
for _ in range(6):
    as1200.add_layer(FilteredFieldLayer((270, 5), alpha=0.5, cax_offset_mm=(1, 1)))
    as1200.image = ndimage.rotate(as1200.image, 60, reshape=False, mode='nearest')
as1200.add_layer(GaussianFilterLayer(sigma_mm=3))
as1200.generate_dicom(file_out_name=star_path)

# analyze it
star = pylinac.Starshot(star_path)
star.analyze()
print(star.results())
star.plot_analyzed_image()

(Source code, png, hires.png, pdf)

_images/starshot_docs-4.png

with an output of:

Result: FAIL

The minimum circle that touches all the star lines has a diameter of 1.035 mm.

The center of the minimum circle is at 637.8, 633.3

Note that we still have the 0.035mm error from the algorithm uncertainty but that we have caught the 1mm offset appropriately.

API Documentation

class pylinac.starshot.Starshot(filepath: Union[str, BinaryIO], **kwargs)[source]

Bases: object

Class that can determine the wobble in a “starshot” image, be it gantry, collimator, couch or MLC. The image can be a scanned film (TIF, JPG, etc) or a sequence of EPID DICOM images.

image
Type:Image
circle_profile
Type:StarProfile
lines
Type:LineManager
wobble
Type:Wobble
tolerance
Type:Tolerance

Examples

Run the demo:
>>> Starshot.run_demo()
Typical session:
>>> img_path = r"C:/QA/Starshots/Coll.jpeg"
>>> mystar = Starshot(img_path, dpi=105, sid=1000)
>>> mystar.analyze()
>>> print(mystar.results())
>>> mystar.plot_analyzed_image()
Parameters:
  • filepath – The path to the image file.
  • kwargs – Passed to load().
classmethod from_url(url: str, **kwargs)[source]

Instantiate from a URL.

Parameters:
  • url (str) – URL of the raw file.
  • kwargs – Passed to load().
classmethod from_demo_image()[source]

Construct a Starshot instance and load the demo image.

classmethod from_multiple_images(filepath_list: list, **kwargs)[source]

Construct a Starshot instance and load in and combine multiple images.

Parameters:
  • filepath_list (iterable) – An iterable of file paths to starshot images that are to be superimposed.
  • kwargs – Passed to load_multiples().
classmethod from_zip(zip_file: str, **kwargs)[source]

Construct a Starshot instance from a ZIP archive.

Parameters:
  • zip_file (str) – Points to the ZIP archive. Can contain a single or multiple images. If multiple images the images are combined and thus should be from the same test sequence.
  • kwargs – Passed to load_multiples().
analyze(radius: float = 0.85, min_peak_height: float = 0.25, tolerance: float = 1.0, start_point: Union[pylinac.core.geometry.Point, tuple, None] = None, fwhm: bool = True, recursive: bool = True, invert: bool = False)[source]

Analyze the starshot image.

Analyze finds the minimum radius and center of a circle that touches all the lines (i.e. the wobble circle diameter and wobble center).

Parameters:
  • radius (float, optional) – Distance in % between starting point and closest image edge; used to build the circular profile which finds the radiation lines. Must be between 0.05 and 0.95.
  • min_peak_height (float, optional) – The percentage minimum height a peak must be to be considered a valid peak. A lower value catches radiation peaks that vary in magnitude (e.g. different MU delivered or gantry shot), but could also pick up noise. If necessary, lower value for gantry shots and increase for noisy images.
  • tolerance (int, float, optional) – The tolerance in mm to test against for a pass/fail result.
  • start_point (2-element iterable, optional) – The point where the algorithm should center the circle profile, given as (x-value, y-value). If None (default), will search for a reasonable maximum point nearest the center of the image.
  • fwhm (bool) –

    If True (default), the center of the FWHM of the spokes will be determined. If False, the peak value location is used as the spoke center.

    Note

    In practice, this ends up being a very small difference. Set to false if peak locations are offset or unexpected.

  • recursive (bool) –

    If True (default), will recursively search for a “reasonable” wobble, meaning the wobble radius is <3mm. If the wobble found was unreasonable, the minimum peak height is iteratively adjusted from low to high at the passed radius. If for all peak heights at the given radius the wobble is still unreasonable, the radius is then iterated over from most distant inward, iterating over minimum peak heights at each radius. If False, will simply return the first determined value or raise error if a reasonable wobble could not be determined.

    Warning

    It is strongly recommended to leave this setting at True.

  • invert (bool) – Whether to force invert the image values. This should be set to True if the automatically-determined pylinac inversion is incorrect.
Raises:

RuntimeError – If a reasonable wobble value was not found.

passed

Boolean specifying whether the determined wobble was within tolerance.

results() → str[source]

Return the results of the analysis.

Returns:A string with a statement of the minimum circle.
Return type:string
results_data(as_dict: bool = False) → Union[pylinac.starshot.StarshotResults, dict][source]

Present the results data and metadata as a dataclass or dict. The default return type is a dataclass.

plot_analyzed_image(show: bool = True)[source]

Draw the star lines, profile circle, and wobble circle on a matplotlib figure.

Parameters:show (bool) – Whether to actually show the image.
plot_analyzed_subimage(subimage: str = 'wobble', ax: Optional[matplotlib.axes._axes.Axes] = None, show: bool = True)[source]

Plot a subimage of the starshot analysis. Current options are the zoomed out image and the zoomed in image.

Parameters:
  • subimage (str) – If ‘wobble’, will show a zoomed in plot of the wobble circle. Any other string will show the zoomed out plot.
  • ax (None, matplotlib Axes) – If None (default), will create a new figure to plot on, otherwise plot to the passed axes.
save_analyzed_image(filename: str, **kwargs)[source]

Save the analyzed image plot to a file.

Parameters:
  • filename (str, IO stream) – The filename to save as. Format is deduced from string extention, if there is one. E.g. ‘mystar.png’ will produce a PNG image.
  • kwargs – All other kwargs are passed to plt.savefig().
save_analyzed_subimage(filename: str, subimage: str = 'wobble', **kwargs)[source]

Save the analyzed subimage to a file.

Parameters:
  • filename (str, file-object) – Where to save the file to.
  • subimage (str) – If ‘wobble’, will show a zoomed in plot of the wobble circle. Any other string will show the zoomed out plot.
  • kwargs – Passed to matplotlib.
publish_pdf(filename: Union[str, IO], notes: Union[str, List[str], None] = None, open_file: bool = False, metadata: Optional[dict] = None)[source]

Publish (print) a PDF containing the analysis, images, and quantitative results.

Parameters:
  • filename ((str, file-like object}) – The file to write the results to.
  • notes (str, list of strings) – Text; if str, prints single line. If list of strings, each list item is printed on its own line.
  • open_file (bool) – Whether to open the file using the default program after creation.
  • metadata (dict) – Extra data to be passed and shown in the PDF. The key and value will be shown with a colon. E.g. passing {‘Author’: ‘James’, ‘Unit’: ‘TrueBeam’} would result in text in the PDF like: ————– Author: James Unit: TrueBeam ————–
static run_demo()[source]

Demonstrate the Starshot module using the demo image.

class pylinac.starshot.StarshotResults(tolerance_mm: float, circle_diameter_mm: float, circle_radius_mm: float, passed: bool, circle_center_x_y: Tuple[float, float])[source]

Bases: pylinac.core.utilities.ResultBase

This class should not be called directly. It is returned by the results_data() method. It is a dataclass under the hood and thus comes with all the dunder magic.

Use the following attributes as normal class attributes.

tolerance_mm = None
circle_diameter_mm = None
circle_radius_mm = None
passed = None
circle_center_x_y = None
class pylinac.starshot.StarProfile(image, start_point, radius, min_peak_height, fwhm)[source]

Bases: pylinac.core.profile.CollapsedCircleProfile

Class that holds and analyzes the circular profile which finds the radiation lines.

get_peaks(min_peak_height, min_peak_distance=0.02, fwhm=True)[source]

Determine the peaks of the profile.

class pylinac.starshot.Wobble(center_point=None, radius=None)[source]

Bases: pylinac.core.geometry.Circle

A class that holds the wobble information of the Starshot analysis.

radius_mm
Type:The radius of the Circle in mm.
diameter_mm

Diameter of the wobble in mm.

class pylinac.starshot.LineManager(points: List[pylinac.core.geometry.Point])[source]

Bases: object

Manages the radiation lines found.

Parameters:points – The peak points found by the StarProfile
construct_rad_lines(points: List[pylinac.core.geometry.Point])[source]
Find and match the positions of peaks in the circle profile (radiation lines)
and map their positions to the starshot image.

Radiation lines are found by finding the FWHM of the radiation spokes, then matching them to form lines.

Returns:lines – A list of Lines (radiation lines) found.
Return type:list

See also

Starshot.analyze()

core.profile.CircleProfile.find_FWXM_peaks()
min_peak_distance parameter info.
geometry.Line()
returning object
match_points(points: List[pylinac.core.geometry.Point])[source]

Match the peaks found to the same radiation lines.

Peaks are matched by connecting the existing peaks based on an offset of peaks. E.g. if there are 12 peaks, there must be 6 radiation lines. Furthermore, assuming star lines go all the way across the CAX, the 7th peak will be the opposite peak of the 1st peak, forming a line. This method is robust to starting points far away from the real center.

plot(axis: matplotlib.axes._axes.Axes)[source]

Plot the lines to the axis.