Skip to content

Commit

Permalink
Update Measure and EchoMeasure to add new measures and standardiz…
Browse files Browse the repository at this point in the history
…e existing ones (#182)

* Standardize the units of values returned (and/or the documentation of the return value) for previous methods: `surface_area`, `structure_area_split_by_endo_center_line`, `lv_base_width` and `lv_length`. The units were chosen as ones values in the literature are typically reported in.

* Add new `EchoMeasure` methods + test of the new measures on synthetic examples:
  - `myo_thickness`
  - `curvature`
  • Loading branch information
nathanpainchaud authored Nov 8, 2023
1 parent bf513a0 commit af24988
Show file tree
Hide file tree
Showing 5 changed files with 322 additions and 8 deletions.
3 changes: 2 additions & 1 deletion vital/utils/image/measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
219 changes: 212 additions & 7 deletions vital/utils/image/us/measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -374,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:
Expand Down Expand Up @@ -515,16 +516,17 @@ 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)

# Identify the base of the left ventricle
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
Expand All @@ -544,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)

Expand All @@ -556,4 +558,207 @@ 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
@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
Empty file.
44 changes: 44 additions & 0 deletions vital/utils/image/us/tests/curvature_test.py
Original file line number Diff line number Diff line change
@@ -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()
64 changes: 64 additions & 0 deletions vital/utils/image/us/tests/myo_thickness_test.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit af24988

Please sign in to comment.