From d2b2b122245ff5a2aaf08b546aca540c6686146a Mon Sep 17 00:00:00 2001 From: Nathan Painchaud Date: Wed, 8 Nov 2023 17:58:04 +0100 Subject: [PATCH 1/5] Define new attributes to extract from images - myo_thickness - curvature --- vital/utils/image/us/measure.py | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/vital/utils/image/us/measure.py b/vital/utils/image/us/measure.py index c96492ce..0064c90e 100644 --- a/vital/utils/image/us/measure.py +++ b/vital/utils/image/us/measure.py @@ -7,6 +7,7 @@ from scipy import ndimage from scipy.ndimage import gaussian_filter1d from scipy.signal import find_peaks +from scipy.spatial import distance from skimage.measure import find_contours from vital.data.config import SemanticStructureId @@ -557,3 +558,204 @@ def lv_length( # Compute the distance between the apex and the base's midpoint return np.linalg.norm((apex - base_mid) * voxelspacing) + + @staticmethod + @auto_cast_data + @batch_function(item_ndim=2) + def myo_thickness( + segmentation: T, + lv_labels: SemanticStructureId, + myo_labels: SemanticStructureId, + num_control_points: int = 31, + control_points_slice: slice = None, + voxelspacing: Tuple[float, float] = (1, 1), + debug_plots: bool = False, + ) -> T: + """Measures the average endo-epi distance orthogonal to the centerline over a given number of control points. + + Args: + segmentation: ([N], H, W), Segmentation map. + lv_labels: Labels of the classes that are part of the left ventricle. + myo_labels: Labels of the classes that are part of the myocardium. + num_control_points: Number of control points to sample along the contour of the endocardium/epicardium. The + number of control points should be odd to be divisible evenly between the base -> apex and apex -> base + segments. + control_points_slice: Slice of control points to consider when computing the curvature. This is useful to + compute the curvature over a subset of the control points, e.g. to compute the thickness over the basal + septum in A4C. + voxelspacing: Size of the segmentation's voxels along each (height, width) dimension (in mm). + debug_plots: Whether to plot the thickness at each centerline point. This is done by plotting the + value of the thic at each control point as a color-coded scatter plot on top of the segmentation. + This should only be used for debugging the expected value of the curvature. + + Returns: + ([N,] `control_points_slice`), Thickness of the myocardium over the requested segmentm (in cm). + """ + # Extract control points along the myocardium's centerline + centerline_pix = EchoMeasure.control_points( + segmentation, lv_labels, myo_labels, "myo", num_control_points, voxelspacing=voxelspacing + ) + voxelspacing = np.array(voxelspacing) + + # Compute the direction of the centerline at each control point as the vector between the previous and next + # points. However, for the first and last points, we simply use the vector between the point itself and its next + # or previous point, respectively (in practice, this is implemented by edge-padding the centerline points) + centerline_pad = np.pad(centerline_pix, ((1, 1), (0, 0)), mode="edge") + centerline_vecs = centerline_pad[2:] - centerline_pad[:-2] # (num_control_points, 2) + + # Compute the vectors orthogonal to the centerline at each control point, defined as the cross product between + # the centerline's direction and an arbitrary vector out of the plane of the image (i.e. the z-axis) + r90_matrix = np.array([[0, -1], [1, 0]]) + orth_vecs = centerline_vecs @ r90_matrix # (num_control_points, 2) + # Normalize the orthogonal vectors to be 1mm in length + unit_orth_vecs = (orth_vecs * voxelspacing) / np.linalg.norm((orth_vecs * voxelspacing), axis=1, keepdims=True) + + # Sample points every mm (up to 1.2cm) along the orthogonal vectors towards both the endo and epi contours + centerline = centerline_pix * voxelspacing # (num_control_points, 2) + sample_offsets = unit_orth_vecs[:, None, :] * np.arange(1, 13)[None, :, None] # (num_control_points, 12, 2) + centerline_inner_samples = centerline[:, None] + sample_offsets # (num_control_points, 12, 2) + centerline_outer_samples = centerline[:, None] - sample_offsets # (num_control_points, 12, 2) + + # Extract the endo and epi contours, in both pixel and physical coordinates + endo_contour_pix, epi_contour_pix = [ + find_contours(np.isin(segmentation, struct_labels), level=0.9)[0] + for struct_labels in [lv_labels, [lv_labels, myo_labels]] + ] + endo_contour, epi_contour = endo_contour_pix * voxelspacing, epi_contour_pix * voxelspacing + + # For each centerline point, find the nearest point on the endo and epi contours to the points sampled along the + # orthogonal vector + endo_closest_orth, epi_closest_orth = [], [] + for inner_samples, outer_samples in zip(centerline_inner_samples, centerline_outer_samples): + endo_dist = distance.cdist(inner_samples, endo_contour) # (12, `len(endo_contour)`) + closest_endo_idx = np.unravel_index(np.argmin(endo_dist), endo_dist.shape)[1] + endo_closest_orth.append(endo_contour_pix[closest_endo_idx]) + + epi_dist = distance.cdist(outer_samples, epi_contour) # (12, `len(epi_contour)`) + closest_epi_idx = np.unravel_index(np.argmin(epi_dist), epi_dist.shape)[1] + epi_closest_orth.append(epi_contour_pix[closest_epi_idx]) + + endo_closest_orth, epi_closest_orth = np.array(endo_closest_orth), np.array(epi_closest_orth) + + # Compute the thickness as the distance between the points on the endo and epi contours that are closest to the + # points sampled along the orthogonal vector + thickness = np.linalg.norm((endo_closest_orth - epi_closest_orth) * voxelspacing, axis=1) + thickness *= 1e-1 # Convert from mm to cm + + # Only keep the control points that are part of the requested segment, if any + if control_points_slice: + thickness = thickness[control_points_slice] + + if debug_plots: + from matplotlib import pyplot as plt + + if control_points_slice: + centerline_pix = centerline_pix[control_points_slice] + endo_closest_orth = endo_closest_orth[control_points_slice] + epi_closest_orth = epi_closest_orth[control_points_slice] + + plt.imshow(segmentation) + for points in [centerline_pix, endo_closest_orth, epi_closest_orth]: + plt.scatter(points[:, 1], points[:, 0], c=thickness, cmap="magma", marker="o", s=3) + + # Annotate the centerline points with their respective thickness value + for c_coord, c_thickness in zip(centerline_pix, thickness): + plt.annotate( + f"{c_thickness:.1f}", + (c_coord[1], c_coord[0]), + xytext=(2, 0), + textcoords="offset points", + fontsize="small", + ) + + plt.show() + + # Average the metric over the control points + return thickness + + @staticmethod + @auto_cast_data + @batch_function(item_ndim=2) + def curvature( + segmentation: T, + lv_labels: SemanticStructureId, + myo_labels: SemanticStructureId, + structure: Literal["endo", "epi"], + num_control_points: int = 31, + control_points_slice: slice = None, + voxelspacing: Tuple[float, float] = (1, 1), + debug_plots: bool = False, + ) -> T: + """Measures the average curvature of the endocardium/epicardium over a given number of control points. + + References: + - Uses the specific definition of curvature proposed by Marciniak et al. (2021) in + https://doi.org/10.1097/HJH.0000000000002813 + + Args: + segmentation: ([N], H, W), Segmentation map. + lv_labels: Labels of the classes that are part of the left ventricle. + myo_labels: Labels of the classes that are part of the myocardium. + num_control_points: Number of control points to sample along the contour of the structure. The number of + control points should be odd to be divisible evenly between the base -> apex and apex -> base segments. + control_points_slice: Slice of control points to consider when computing the curvature. This is useful to + compute the curvature over a subset of the control points, e.g. to compute the curvature over the basal + septum in A4C. + voxelspacing: Size of the segmentation's voxels along each (height, width) dimension (in mm). + debug_plots: Whether to plot the value of the curvature at each control point. This is done by plotting the + value of the curvature at each control point as a color-coded scatter plot on top of the segmentation. + This should only be used for debugging the expected value of the curvature. + + Returns: + ([N,] `control_points_slice`), Curvature of the endocardium over the requested segment (in dm^-1). + """ + # Extract control points along the structure's contour + control_points_pix = EchoMeasure.control_points( + segmentation, lv_labels, myo_labels, structure, num_control_points, voxelspacing=voxelspacing + ) # (num_control_points, 2) + # Convert pixel coordinates to physical coordinates + control_points = control_points_pix * np.array(voxelspacing) + + # Re-organize the control points into arrays of x and y coordinates + y_coords, x_coords = control_points.T + + # Compute the curvature at each control point + # 1. Compute the first and second derivatives of the x and y coordinates + dx, dy = np.gradient(x_coords, edge_order=2), np.gradient(y_coords, edge_order=2) + dx2, dy2 = np.gradient(dx), np.gradient(dy) + + # 2. Compute the curvature using Eq. 1 from the paper by Marciniak et al. + k = (dx2 * dy - dx * dy2) / ((dx**2 + dy**2) ** (3 / 2)) + k *= 1e2 # Convert from mm^-1 to dm^-1 + + # Only keep the control points that are part of the requested segment, if any + if control_points_slice: + k = k[control_points_slice] + + if debug_plots: + import matplotlib.colors as colors + from matplotlib import pyplot as plt + + selected_c_points = control_points_pix + if control_points_slice: + selected_c_points = selected_c_points[control_points_slice] + + plt.imshow(segmentation) + plt.scatter( + selected_c_points[:, 1], + selected_c_points[:, 0], + c=k, + cmap="seismic", + norm=colors.TwoSlopeNorm(vmin=-10, vcenter=0, vmax=10), + marker="o", + s=3, + ) + # Annotate the control points with their respective curvature value + for c_coord, c_k in zip(selected_c_points, k): + plt.annotate( + f"{c_k:.1f}", (c_coord[1], c_coord[0]), xytext=(2, 0), textcoords="offset points", fontsize="small" + ) + + plt.show() + + return k From 06e1e97f77c9a9b38a7d1289f237e47e65313d36 Mon Sep 17 00:00:00 2001 From: Nathan Painchaud Date: Wed, 8 Nov 2023 18:06:38 +0100 Subject: [PATCH 2/5] Standardized return value units for previous attributes --- vital/utils/image/measure.py | 3 ++- vital/utils/image/us/measure.py | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/vital/utils/image/measure.py b/vital/utils/image/measure.py index 74f524b3..100634bc 100644 --- a/vital/utils/image/measure.py +++ b/vital/utils/image/measure.py @@ -28,7 +28,8 @@ def structure_area(segmentation: T, labels: SemanticStructureId = None, voxelare voxelarea: Size of the mask's voxels along each (height, width) dimension (in mm). Returns: - ([N]), Number of pixels associated to the structure, in each segmentation of the batch. + ([N]), Surface associated to the structure (in mm² if `voxelspacing` and pixels otherwise), in each + segmentation of the batch. """ if labels: mask = np.isin(segmentation, labels) diff --git a/vital/utils/image/us/measure.py b/vital/utils/image/us/measure.py index 0064c90e..c415ee87 100644 --- a/vital/utils/image/us/measure.py +++ b/vital/utils/image/us/measure.py @@ -375,8 +375,8 @@ def structure_area_split_by_endo_center_line( voxelspacing: Size of the segmentation's voxels along each (height, width) dimension (in mm). Returns: - ([N]), Number of pixels associated with the structure that falls on the left/right side of the endo center - line, in each segmentation of the batch. + ([N]), Surface associated with the structure (in mm² if `voxelspacing` and pixels otherwise) that falls on + the left/right side of the endo center line, in each segmentation of the batch. """ # Find the binary mask of the structure if labels: @@ -516,8 +516,7 @@ def lv_base_width( voxelspacing: Size of the segmentation's voxels along each (height, width) dimension (in mm). Returns: - ([N]), Distance between the left and right markers at the base of the left ventricle, or NaNs for the - images where those 2 points cannot be reliably estimated. + ([N]), Distance between the left and right markers at the base of the left ventricle (in cm). """ voxelspacing = np.array(voxelspacing) @@ -525,7 +524,9 @@ def lv_base_width( left_corner, right_corner = EchoMeasure._endo_base(segmentation, lv_labels, myo_labels) # Compute the distance between the points at the base - return np.linalg.norm((left_corner - right_corner) * voxelspacing) + width = np.linalg.norm((left_corner - right_corner) * voxelspacing) + width *= 1e-1 # Convert from mm to cm + return width @staticmethod @auto_cast_data @@ -545,7 +546,7 @@ def lv_length( voxelspacing: Size of the segmentation's voxels along each (height, width) dimension (in mm). Returns: - ([N]), Length of the left ventricle. + ([N]), Length of the left ventricle (in cm). """ voxelspacing = np.array(voxelspacing) @@ -557,7 +558,9 @@ def lv_length( )[0] # Compute the distance between the apex and the base's midpoint - return np.linalg.norm((apex - base_mid) * voxelspacing) + length = np.linalg.norm((apex - base_mid) * voxelspacing) + length *= 1e-1 # Convert from mm to cm + return length @staticmethod @auto_cast_data From 260803b3a318d36ec4b83b527293ab33d7100254 Mon Sep 17 00:00:00 2001 From: Nathan Painchaud Date: Wed, 8 Nov 2023 22:43:48 +0100 Subject: [PATCH 3/5] Test for the curvature attribute on a circle of known radius --- vital/utils/image/us/tests/__init__.py | 0 vital/utils/image/us/tests/curvature_test.py | 44 ++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 vital/utils/image/us/tests/__init__.py create mode 100644 vital/utils/image/us/tests/curvature_test.py diff --git a/vital/utils/image/us/tests/__init__.py b/vital/utils/image/us/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vital/utils/image/us/tests/curvature_test.py b/vital/utils/image/us/tests/curvature_test.py new file mode 100644 index 00000000..1552c695 --- /dev/null +++ b/vital/utils/image/us/tests/curvature_test.py @@ -0,0 +1,44 @@ +def main(): + """Run the test script.""" + import argparse + from math import ceil + from unittest.mock import patch + + import numpy as np + + from vital.utils.image.us.measure import EchoMeasure + + parser = argparse.ArgumentParser(description="Compute the curvature of a circle of known radius.") + parser.add_argument("--radius", type=float, default=50, help="Radius of the circle") + parser.add_argument("--num_points", type=int, default=40, help="Number of points to sample along the circle") + parser.add_argument("--voxel_size", type=float, default=0.2, help="Voxel size (in mm)") + parser.add_argument("--debug_plot", action="store_true", help="Whether to plot the curvature") + args = parser.parse_args() + + def sample_circle(radius: float, num_points: int, pixel_coords: bool = False) -> np.ndarray: + """Sample points along the circumference of a circle of known radius.""" + theta = np.linspace(0, 2 * np.pi, num_points) + x = radius * np.cos(theta) + y = radius * np.sin(theta) + coords = np.vstack([y, x]).T + if pixel_coords: + # Convert from exact floating-point coordinates to integer coordinates + # + offset from origin so that the circle falls in the positive quadrant + coords = coords.astype(int) + ceil(radius) + return coords + + circle_samples = sample_circle(args.radius, args.num_points, pixel_coords=args.debug_plot) + mask = np.zeros(np.ceil(circle_samples.max(axis=0)).astype(int) + 1) + + # Mock the measure object to return a known set of control points that the domain-specific code could not extract + # from the circle + with patch.object(EchoMeasure, "control_points", return_value=circle_samples): + # Compute the curvature of the circle + curvature = EchoMeasure.curvature( + mask, None, None, None, num_control_points=60, voxelspacing=args.voxel_size, debug_plots=args.debug_plot + ) + print(f"Computed curvature (in dm^-1): {curvature}") + + +if __name__ == "__main__": + main() From edb4deb77db47fad96c6ea364c68bcd81a24a226 Mon Sep 17 00:00:00 2001 From: Nathan Painchaud Date: Wed, 8 Nov 2023 22:44:06 +0100 Subject: [PATCH 4/5] Test for the myo thickness attribute on concentric circles of known radii --- .../image/us/tests/myo_thickness_test.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 vital/utils/image/us/tests/myo_thickness_test.py diff --git a/vital/utils/image/us/tests/myo_thickness_test.py b/vital/utils/image/us/tests/myo_thickness_test.py new file mode 100644 index 00000000..b7085aed --- /dev/null +++ b/vital/utils/image/us/tests/myo_thickness_test.py @@ -0,0 +1,64 @@ +def main(): + """Run the test script.""" + import argparse + from math import ceil + from unittest.mock import patch + + import numpy as np + from skimage.morphology import disk + + from vital.utils.image.us.measure import EchoMeasure + + parser = argparse.ArgumentParser( + description="Compute the thickness of the band between an inner and an outer circle of known radii." + ) + parser.add_argument("--in_radius", type=float, default=80, help="Radius of the inner circle") + parser.add_argument("--out_radius", type=float, default=100, help="Radius of the outer circle") + parser.add_argument( + "--num_points", type=int, default=40, help="Number of points to sample in the center of the band" + ) + parser.add_argument("--voxel_size", type=float, default=0.5, help="Voxel size (in mm)") + parser.add_argument("--debug_plot", action="store_true", help="Whether to plot the curvature") + args = parser.parse_args() + + radius_diff = args.out_radius - args.in_radius + + def sample_circle(radius: float, num_points: int, pixel_coords: bool = False) -> np.ndarray: + """Sample points along the circumference of a circle of known radius.""" + # Generate points in clockwise order, because that's what the function for computing the curvature expects + theta = np.linspace(0, 2 * np.pi, num_points) + x = radius * np.cos(theta) + y = radius * np.sin(theta) + coords = np.vstack([y, x]).T + if pixel_coords: + # Convert from exact floating-point coordinates to integer coordinates + # + offset from origin so that the circle falls between the inner and outer circles in the positive quadrant + # + additional offset of 1 to account for the padding of the mask + coords = coords.astype(int) + ceil(radius) + (radius_diff // 2) + 1 + return coords + + center_samples = sample_circle( + (args.out_radius + args.in_radius) // 2, args.num_points, pixel_coords=args.debug_plot + ) + # Create masks of the inner/outer circles + # + pad the inner circle by the difference in radii so that its center matches the outer circle's + outer_mask = disk(args.out_radius) + inner_mask = np.pad(disk(args.in_radius), ((radius_diff, radius_diff), (radius_diff, radius_diff)), mode="constant") + + # Combine the inner and outer and assign them the appropriate labels + mask = outer_mask * 2 + mask[inner_mask.astype(bool)] = 1 + mask = np.pad(mask, ((1, 1), (1, 1)), mode="constant") + + # Mock the measure object to return a known set of control points that the domain-specific code could not extract + # from the circle + with patch.object(EchoMeasure, "control_points", return_value=center_samples): + # Compute the curvature of the circle + thickness = EchoMeasure.myo_thickness( + mask, 1, 2, num_control_points=60, voxelspacing=args.voxel_size, debug_plots=args.debug_plot + ) + print(f"Computed thickness (in cm): {thickness}") + + +if __name__ == "__main__": + main() From 8e8eb9efb4b7f48496c6414dacb6a3108a0f816e Mon Sep 17 00:00:00 2001 From: Nathan Painchaud Date: Wed, 8 Nov 2023 18:56:55 +0100 Subject: [PATCH 5/5] Fix myo thickness to not depend on clockwise order of control points --- vital/utils/image/us/measure.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/vital/utils/image/us/measure.py b/vital/utils/image/us/measure.py index c415ee87..321d8988 100644 --- a/vital/utils/image/us/measure.py +++ b/vital/utils/image/us/measure.py @@ -616,8 +616,14 @@ def myo_thickness( # Sample points every mm (up to 1.2cm) along the orthogonal vectors towards both the endo and epi contours centerline = centerline_pix * voxelspacing # (num_control_points, 2) sample_offsets = unit_orth_vecs[:, None, :] * np.arange(1, 13)[None, :, None] # (num_control_points, 12, 2) - centerline_inner_samples = centerline[:, None] + sample_offsets # (num_control_points, 12, 2) - centerline_outer_samples = centerline[:, None] - sample_offsets # (num_control_points, 12, 2) + centerline_orth_segments = np.concatenate( + [ + centerline[:, None] - sample_offsets, # (num_control_points, 12, 2) + centerline[:, None], # (num_control_points, 1, 2) + centerline[:, None] + sample_offsets, # (num_control_points, 12, 2) + ], + axis=1, + ) # (num_control_points, 25, 2) # Extract the endo and epi contours, in both pixel and physical coordinates endo_contour_pix, epi_contour_pix = [ @@ -627,14 +633,14 @@ def myo_thickness( endo_contour, epi_contour = endo_contour_pix * voxelspacing, epi_contour_pix * voxelspacing # For each centerline point, find the nearest point on the endo and epi contours to the points sampled along the - # orthogonal vector + # orthogonal segment endo_closest_orth, epi_closest_orth = [], [] - for inner_samples, outer_samples in zip(centerline_inner_samples, centerline_outer_samples): - endo_dist = distance.cdist(inner_samples, endo_contour) # (12, `len(endo_contour)`) + for orth_segment in centerline_orth_segments: + endo_dist = distance.cdist(orth_segment, endo_contour) # (25, `len(endo_contour)`) closest_endo_idx = np.unravel_index(np.argmin(endo_dist), endo_dist.shape)[1] endo_closest_orth.append(endo_contour_pix[closest_endo_idx]) - epi_dist = distance.cdist(outer_samples, epi_contour) # (12, `len(epi_contour)`) + epi_dist = distance.cdist(orth_segment, epi_contour) # (25, `len(epi_contour)`) closest_epi_idx = np.unravel_index(np.argmin(epi_dist), epi_dist.shape)[1] epi_closest_orth.append(epi_contour_pix[closest_epi_idx])