Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

397 standardise docstrings #398

Merged
merged 33 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
07b4347
black formatting and docstrings
sophie22 Dec 24, 2023
9916858
blacks formatting
sophie22 Dec 24, 2023
719d38c
add test that report image is created
sophie22 Dec 24, 2023
7b7e14f
blacks formatting, add docstrings
sophie22 Dec 28, 2023
a65b324
simplify get_rods()
sophie22 Dec 28, 2023
d03a4d2
standardise docstrings, add TODOs
sophie22 Dec 28, 2023
8cb8d8f
blacks formatting
sophie22 Dec 28, 2023
44006d2
add test for report image creation
sophie22 Dec 28, 2023
9ea7055
blacks formatting
sophie22 Dec 28, 2023
ab4e8d5
blacks formatting
sophie22 Dec 28, 2023
a74b1a5
add doctsrings
sophie22 Jan 2, 2024
ee97c48
blacks formatting
sophie22 Jan 4, 2024
a0100b8
Merge branch '400-blacks-formatting' into 397-standardise-docstrings
sophie22 Jan 4, 2024
daf85ec
incorrectly resolved merge conflict caused erroneous duplication of s…
sophie22 Jan 4, 2024
eead16e
revert phantom centre function
sophie22 Jan 4, 2024
325a588
specify circle radius value
sophie22 Jan 4, 2024
cbccd63
remove test for report image generation
sophie22 Jan 4, 2024
9354045
revert test setup
sophie22 Jan 4, 2024
6b38c84
add docstrings
sophie22 Jan 4, 2024
7823859
update attribute name
sophie22 Jan 4, 2024
8ab0474
add docstrings
sophie22 Jan 4, 2024
04c4bea
add placeholder docstrings
sophie22 Jan 5, 2024
abec358
correct task description
sophie22 Jan 5, 2024
575974f
add docstrings
sophie22 Jan 10, 2024
a382755
add docstrings
sophie22 Jan 10, 2024
e2c2ed2
Merge branch 'main' into 397-standardise-docstrings
sophie22 Jan 16, 2024
8bbe055
add docstrings
sophie22 Jan 16, 2024
51ec774
add docstrings
sophie22 Jan 16, 2024
35d301d
add docstrings
sophie22 Jan 16, 2024
feb29c1
corrected typos and minor clarifications
sophie22 Jan 17, 2024
6ec7449
additional description
sophie22 Jan 17, 2024
1e984da
additional docstrings
sophie22 Jan 17, 2024
08262f8
remove test cases that are not possible to occur with the current imp…
sophie22 Jan 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions hazenlib/ACRObject.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ def __init__(self, dcm_list):
self.dcm_list = dcm_list
# Load files as DICOM and their pixel arrays into 'images'
self.images, self.dcms = self.sort_images()
# Store the DCM object of slice 7 as it is used often
self.slice7_dcm = self.dcms[6]
# Store the pixel spacing value from the first image (expected to be the same for all)
self.pixel_spacing = self.dcms[0].PixelSpacing
# Check whether images of the phantom are the correct orientation
self.orientation_checks()
# Determine whether image rotation is necessary
self.rot_angle = self.determine_rotation()
# Store the DCM object of slice 7 as it is used often
self.slice7_dcm = self.dcms[6]
# Find the centre coordinates of the phantom (circle)
self.centre, self.radius = self.find_phantom_center()
# Store a mask image of slice 7 for reusability
Expand All @@ -35,6 +35,11 @@ def sort_images(self):
A sorted stack of dicoms
"""

# TODO: implement a check if phantom was placed in other than axial position
# This is to be able to flag to the user the caveat of measurments if deviating from ACR guidance

# x = np.array([dcm.ImagePositionPatient[0] for dcm in self.dcm_list])
# y = np.array([dcm.ImagePositionPatient[1] for dcm in self.dcm_list])
z = np.array([dcm.ImagePositionPatient[2] for dcm in self.dcm_list])
dicom_stack = [self.dcm_list[i] for i in np.argsort(z)]
img_stack = [dicom.pixel_array for dicom in dicom_stack]
Expand Down Expand Up @@ -151,6 +156,7 @@ def find_phantom_center(self):
"""
img = self.images[6]
dx, dy = self.pixel_spacing

img_blur = cv2.GaussianBlur(img, (1, 1), 0)
img_grad = cv2.Sobel(img_blur, 0, dx=1, dy=1)

Expand All @@ -164,20 +170,23 @@ def find_phantom_center(self):
minRadius=int(180 / (2 * dy)),
maxRadius=int(200 / (2 * dx)),
).flatten()

centre = [int(i) for i in detected_circles[:2]]
radius = int(detected_circles[2])
return centre, radius

def get_mask_image(self, image, mag_threshold=0.05, open_threshold=500):
"""
"""Create a masked pixel array
Mask an image by magnitude threshold before applying morphological opening to remove small unconnected
features. The convex hull is calculated in order to accommodate for potential air bubbles.

Returns
-------
np.array:
The masked image.
Args:
image (_type_): _description_
mag_threshold (float, optional): magnitude threshold. Defaults to 0.05.
open_threshold (int, optional): open threshold. Defaults to 500.

Returns:
np.array:
The masked image.
"""
test_mask = self.circular_mask(
self.centre, (80 // self.pixel_spacing[0]), image.shape
Expand Down
45 changes: 45 additions & 0 deletions hazenlib/tasks/acr_geometric_accuracy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Calculates geometric accuracy for slices 1 and 5 of the ACR phantom.

This script calculates the horizontal and vertical lengths of the ACR phantom in Slice 1 in accordance with the ACR Guidance.
This script calculates the horizontal, vertical and diagonal lengths of the ACR phantom in Slice 5 in accordance with the ACR Guidance.

Check failure on line 9 in hazenlib/tasks/acr_geometric_accuracy.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.9)

E501 line too long (135 > 127 characters)

Check failure on line 9 in hazenlib/tasks/acr_geometric_accuracy.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.10)

E501 line too long (135 > 127 characters)
The average distance measurement error, maximum distance measurement error and coefficient of variation of all distance
measurements is reported as recommended by IPEM Report 112, "Quality Control and Artefacts in Magnetic Resonance Imaging".

Expand All @@ -33,11 +33,23 @@


class ACRGeometricAccuracy(HazenTask):
"""Geometric accuracy measurement class for DICOM images of the ACR phantom

Inherits from HazenTask class
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.ACR_obj = ACRObject(self.dcm_list)

def run(self) -> dict:
"""Main function for performing geometric accuracy measurement
using the first and fifth slices from the ACR phantom image set

Returns:
dict: results are returned in a standardised dictionary structure specifying the task name, input DICOM Series Description + SeriesNumber + InstanceNumber, task measurement key-value pairs, optionally path to the generated images for visualisation

Check failure on line 50 in hazenlib/tasks/acr_geometric_accuracy.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.9)

E501 line too long (259 > 127 characters)

Check failure on line 50 in hazenlib/tasks/acr_geometric_accuracy.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.10)

E501 line too long (259 > 127 characters)
"""

# Identify relevant slices
slice1_dcm = self.ACR_obj.dcms[0]
slice5_dcm = self.ACR_obj.dcms[4]
Expand Down Expand Up @@ -89,6 +101,14 @@
return results

def get_geometric_accuracy_slice1(self, dcm):
"""Measure geometric accuracy for slice 1

Args:
dcm (pydicom.Dataset): DICOM image object

Returns:
tuple of float: horizontal and vertical distances
"""
img = dcm.pixel_array

mask = self.ACR_obj.get_mask_image(self.ACR_obj.images[6])
Expand Down Expand Up @@ -147,6 +167,14 @@
return length_dict["Horizontal Distance"], length_dict["Vertical Distance"]

def get_geometric_accuracy_slice5(self, dcm):
"""Measure geometric accuracy for slice 5

Args:
dcm (pydicom.Dataset): DICOM image object

Returns:
tuple of floats: horizontal and vertical distances, as well as diagonals (SW, SE)
"""
img = dcm.pixel_array
mask = self.ACR_obj.get_mask_image(self.ACR_obj.images[6])
cxy = self.ACR_obj.centre
Expand Down Expand Up @@ -234,6 +262,15 @@
)

def diagonal_lengths(self, img, cxy):
"""Measure diagonal lengths

Args:
img (np.array): dcm.pixel_array
cxy (list): x,y coordinates and radius of the circle

Returns:
tuple of dictionaries: _description_
"""
res = self.ACR_obj.pixel_spacing
eff_res = np.sqrt(np.mean(np.square(res)))
img_rotate = skimage.transform.rotate(img, 45, center=(cxy[0], cxy[1]))
Expand Down Expand Up @@ -280,6 +317,14 @@

@staticmethod
def distortion_metric(L):
"""Calculate the distortion metric based on length

Args:
L (tuple): horizontal and vertical distances from slices 1 and 5

Returns:
tuple of floats: mean_err, max_err, cov_l
"""
err = [x - 190 for x in L]
mean_err = np.mean(err)

Expand Down
20 changes: 20 additions & 0 deletions hazenlib/tasks/acr_ghosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,23 @@


class ACRGhosting(HazenTask):
"""Ghosting measurement class for DICOM images of the ACR phantom

Inherits from HazenTask class
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
# Initialise ACR object
self.ACR_obj = ACRObject(self.dcm_list)

def run(self) -> dict:
"""Main function for performing ghosting measurement
using slice 7 from the ACR phantom image set

Returns:
dict: results are returned in a standardised dictionary structure specifying the task name, input DICOM Series Description + SeriesNumber + InstanceNumber, task measurement key-value pairs, optionally path to the generated images for visualisation

Check failure on line 43 in hazenlib/tasks/acr_ghosting.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.9)

E501 line too long (259 > 127 characters)

Check failure on line 43 in hazenlib/tasks/acr_ghosting.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.10)

E501 line too long (259 > 127 characters)
"""
# Initialise results dictionary
results = self.init_result_dict()
results["file"] = self.img_desc(self.ACR_obj.slice7_dcm)
Expand All @@ -50,6 +62,14 @@
return results

def get_signal_ghosting(self, dcm):
"""Calculate signal ghosting

Args:
dcm (pydicom.Dataset): DICOM image object

Returns:
float: percentage ghosting value
"""
img = dcm.pixel_array
res = dcm.PixelSpacing # In-plane resolution from metadata
r_large = np.ceil(80 / res[0]).astype(
Expand Down
73 changes: 53 additions & 20 deletions hazenlib/tasks/acr_slice_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,23 @@


class ACRSlicePosition(HazenTask):
"""Slice position measurement class for DICOM images of the ACR phantom

Inherits from HazenTask class
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
# Initialise ACR object
self.ACR_obj = ACRObject(self.dcm_list)

def run(self) -> dict:
"""Main function for performing slice position measurement
using the first and last slices from the ACR phantom image set

Returns:
dict: results are returned in a standardised dictionary structure specifying the task name, input DICOM Series Description + SeriesNumber + InstanceNumber, task measurement key-value pairs, optionally path to the generated images for visualisation
"""
# Identify relevant slices
dcms = [self.ACR_obj.dcms[0], self.ACR_obj.dcms[-1]]

Expand All @@ -63,13 +75,24 @@ def run(self) -> dict:
)
traceback.print_exc(file=sys.stdout)
continue

# only return reports if requested
if self.report:
results["report_image"] = self.report_files

return results

def find_wedges(self, img, mask, res):
"""Find wedges in the pixel array

Args:
img (np.array): dcm.pixel_array
mask (np.array): dcm.pixel_array of the image mask
res (float): dcm.PixelSpacing

Returns:
tuple: arrays of x and y coordinates of wedges
"""
# X COORDINATES
x_investigate_region = np.ceil(35 / res[0]).astype(
int
Expand Down Expand Up @@ -177,6 +200,14 @@ def find_wedges(self, img, mask, res):
return x_pts, y_pts

def get_slice_position(self, dcm):
"""Measure slice position

Args:
dcm (pydicom.Dataset): DICOM image object

Returns:
float: bar length difference
"""
img = dcm.pixel_array
res = dcm.PixelSpacing # In-plane resolution from metadata
mask = self.ACR_obj.mask_image
Expand All @@ -195,20 +226,21 @@ def get_slice_position(self, dcm):
1, len(line_prof_L) + (1 / interp_factor), (1 / interp_factor)
)

interp_line_prof_L = scipy.interpolate.interp1d(x, line_prof_L)(
new_x
) # interpolate left line profile
interp_line_prof_R = scipy.interpolate.interp1d(x, line_prof_R)(
new_x
) # interpolate right line profile
# interpolate left line profile
interp_line_prof_L = scipy.interpolate.interp1d(x, line_prof_L)(new_x)

# interpolate right line profile
interp_line_prof_R = scipy.interpolate.interp1d(x, line_prof_R)(new_x)

delta = interp_line_prof_L - interp_line_prof_R # difference of line profiles
# difference of line profiles
delta = interp_line_prof_L - interp_line_prof_R
peaks, _ = ACRObject.find_n_highest_peaks(
abs(delta), 2, 0.5 * np.max(abs(delta))
) # find two highest peaks

# if only one peak, set dummy range
if len(peaks) == 1:
peaks = [peaks[0] - 50, peaks[0] + 50] # if only one peak, set dummy range
peaks = [peaks[0] - 50, peaks[0] + 50]

# set multiplier for right or left shift based on sign of peak
pos = (
Expand All @@ -221,8 +253,11 @@ def get_slice_position(self, dcm):
static_line_L = interp_line_prof_L[peaks[0] : peaks[1]]
static_line_R = interp_line_prof_R[peaks[0] : peaks[1]]

lag = np.linspace(-50, 50, 101, dtype=int) # create array of lag values
err = np.zeros(len(lag)) # initialise array of errors
# create array of lag values
lag = np.linspace(-50, 50, 101, dtype=int)

# initialise array of errors
err = np.zeros(len(lag))

for k, lag_val in enumerate(lag):
difference = static_line_R - np.roll(
Expand All @@ -238,16 +273,14 @@ def get_slice_position(self, dcm):
# calculate difference
err[k] = 1e10 if np.isnan(difference).all() else np.nanmean(difference)

temp = np.argwhere(err == np.min(err[err > 0]))[
0
] # find minimum non-zero error
shift = (
-lag[temp][0] if pos == 1 else lag[temp][0]
) # find shift corresponding to above error

dL = (
pos * np.abs(shift) * (1 / interp_factor) * res[1]
) # calculate bar length difference
# find minimum non-zero error
temp = np.argwhere(err == np.min(err[err > 0]))[0]

# find shift corresponding to above error
shift = -lag[temp][0] if pos == 1 else lag[temp][0]

# calculate bar length difference
dL = pos * np.abs(shift) * (1 / interp_factor) * res[1]

if self.report:
import matplotlib.pyplot as plt
Expand Down
Loading
Loading