retuve.hip_us.metrics.aca
Metric: Acetabular Coverage Angle (ACA)
Meant to be a more accurate version of alpha angle for 3D Ultrasounds.
1# Copyright 2024 Adam McArthur 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15""" 16Metric: Acetabular Coverage Angle (ACA) 17 18Meant to be a more accurate version of alpha angle for 3D Ultrasounds. 19 20""" 21 22import copy 23from enum import Enum 24from typing import Dict, List, Tuple 25 26import numpy as np 27import open3d as o3d 28from numpy.typing import NDArray 29 30from retuve.hip_us.classes.enums import Side 31from retuve.hip_us.classes.general import HipDataUS 32from retuve.hip_us.typing import CoordinatesArray3D 33from retuve.keyphrases.config import Config 34from retuve.keyphrases.enums import ACASplit 35 36 37class SideZ(Enum): 38 """ 39 Enuum for side of the Illiac-Acetabular Bone view for each 2D Frame 40 """ 41 42 LEFT = "illiac wing" 43 RIGHT = "acetabular roof" 44 45 46""" 47Matches the colors of the ACA to the side and third of the Illium. 48""" 49ACA_COLORS = { 50 (SideZ.LEFT, Side.POST): "red", 51 (SideZ.LEFT, Side.GRAF): "orange", 52 (SideZ.LEFT, Side.ANT): "yellow", 53 (SideZ.RIGHT, Side.POST): "blue", 54 (SideZ.RIGHT, Side.GRAF): "cyan", 55 (SideZ.RIGHT, Side.ANT): "lightgreen", 56} 57 58 59class Triangle: 60 """ 61 Class to store the data of a triangle for the ACA calculation. 62 63 :attr apex_side: SideZ: The side of the Illium that the apex is on. 64 :attr third: Side: The third of the Illium that the triangle is in. 65 :attr normal: NDArray[np.float64]: The normal of the triangle. 66 :attr centroid: NDArray[np.float64]: The centroid of the triangle. 67 :attr color: str: The color of the triangle. 68 """ 69 70 def __init__( 71 self, 72 apex_side: SideZ, 73 third: Side, 74 normal: NDArray[np.float64], 75 centroid: NDArray[np.float64], 76 ): 77 self.apex_side = apex_side 78 self.third = third 79 self.normal = normal 80 self.centroid = centroid 81 self.color = ACA_COLORS[(apex_side, third)] 82 83 84def get_aca( 85 illium_mesh: o3d.geometry.TriangleMesh, 86 apex_points: NDArray[np.float64], 87 graf_hip: HipDataUS, 88 no_of_frames: int, 89 config: Config, 90) -> Tuple[ 91 Dict[Side, float], 92 Tuple[CoordinatesArray3D, CoordinatesArray3D, str], 93 List[Triangle], 94 str, 95]: 96 """ 97 Calculate the acetabular coverage angle (ACA) for the hip. 98 99 :param illium_mesh (o3d.geometry.TriangleMesh): The Illium Mesh. 100 :param apex_points (NDArray[np.float64]): The apex points of the Illium. 101 :param graf_hip (HipDataUS): The Hip Data of the Graf Frame. 102 :param no_of_frames (int): The number of frames in the 3D Ultrasound. 103 :param config (Config): The Config object. 104 105 :return: Tuple of the ACA angles for each side, the average normals data, 106 the triangle data, and the recorded error. 107 """ 108 z_gap = config.hip.z_gap * (200 / no_of_frames) 109 110 # add normals as arrows 111 illium_mesh.compute_vertex_normals() 112 normals = np.asarray(illium_mesh.vertex_normals) 113 vertices = np.asarray(illium_mesh.vertices) 114 115 # Compute triangle normals 116 triangle_normals = [] 117 triangle_centroids = [] 118 for tri in illium_mesh.triangles: 119 v0, v1, v2 = vertices[tri] 120 edge1 = v1 - v0 121 edge2 = v2 - v0 122 normal = np.cross(edge1, edge2) 123 centroid = (v0 + v1 + v2) / 3 124 triangle_normals.append(normal) 125 triangle_centroids.append(centroid) 126 127 triangle_normals = np.array(triangle_normals) 128 triangle_centroids = np.array(triangle_centroids) 129 130 # convert normals to unit vectors 131 triangle_normals /= np.linalg.norm(triangle_normals, axis=1)[:, None] 132 133 triangle_data = [] 134 135 max_z = np.max(triangle_centroids[:, 2]) 136 min_z = np.min(triangle_centroids[:, 2]) 137 138 for normal, centroid in zip(triangle_normals, triangle_centroids): 139 if config.hip.aca_split == ACASplit.THIRDS: 140 third_z = (max_z - min_z) / 3 141 142 # find which third the centroid is in 143 if centroid[2] - min_z < third_z: 144 third = Side.POST 145 elif centroid[2] - min_z < third_z * 2: 146 third = Side.GRAF 147 else: 148 third = Side.ANT 149 150 elif config.hip.aca_split == ACASplit.GRAFS: 151 grafs_z = graf_hip.frame_no * z_gap 152 153 grafs_z = grafs_z 154 155 margin = (max_z - min_z) * 0.15 156 157 min_graf_z = grafs_z - margin 158 159 # if it is within 10% of the grafs_z, third = 2 160 if abs(centroid[2] - grafs_z) < margin: 161 third = Side.GRAF 162 elif centroid[2] < min_graf_z: 163 third = Side.POST 164 else: 165 third = Side.ANT 166 167 else: 168 raise ValueError("Invalid ACA Split Config Option") 169 170 apex_points = copy.deepcopy(np.array(apex_points)) 171 172 # get the apex point with the closest z value 173 apex_point = apex_points[ 174 np.argmin(np.abs(apex_points[:, 2] - centroid[2])) 175 ] 176 177 if centroid[0] < apex_point[0]: 178 apex_side = SideZ.LEFT 179 else: 180 apex_side = SideZ.RIGHT 181 182 triangle_data.append( 183 Triangle( 184 apex_side, 185 third, 186 normal, 187 centroid, 188 ) 189 ) 190 191 final_vectors = { 192 # (left, 1): vec 193 } 194 195 avg_normals_data = [] 196 197 # for each color combination, find the average normal and add a cone for it 198 for apex_side in [SideZ.LEFT, SideZ.RIGHT]: 199 for third in Side.ALL(): 200 normals = [] 201 centroids = [] 202 203 for triangle in triangle_data: 204 if triangle.apex_side == apex_side and triangle.third == third: 205 normals.append(triangle.normal) 206 centroids.append(triangle.centroid) 207 208 # This error is recorded in other places. 209 # And will happen when the grafs plane is detected 210 # At the edges of the measured region 211 if len(normals) == 0: 212 continue 213 214 normals = np.array(normals) 215 centroids = np.array(centroids) 216 217 avg_normal = np.mean(normals, axis=0) 218 avg_centroid = np.mean(centroids, axis=0) 219 220 avg_normals_data.append( 221 ( 222 avg_normal, 223 avg_centroid, 224 ACA_COLORS[(apex_side, third)], 225 ) 226 ) 227 228 final_vectors[apex_side, third] = avg_normal 229 230 aca_angles = {} 231 232 # for each third, find the angle between the apex left and right 233 for side in Side.ALL(): 234 try: 235 apex_left = final_vectors[SideZ.LEFT, side] 236 apex_right = final_vectors[SideZ.RIGHT, side] 237 recorded_error = "" 238 except KeyError: 239 aca_angles[side] = 0 240 recorded_error = ( 241 f"Not enough {Side.get_name(side)} to calculate ACA." 242 ) 243 continue 244 245 # get the angle between the two apex points 246 angle = np.arccos( 247 np.dot(apex_left, apex_right) 248 / (np.linalg.norm(apex_left) * np.linalg.norm(apex_right)) 249 ) 250 251 # convert to degrees 252 angle = np.degrees(angle) 253 254 aca_angles[side] = round(angle, 2) 255 256 return aca_angles, avg_normals_data, triangle_data, recorded_error
class
SideZ(enum.Enum):
38class SideZ(Enum): 39 """ 40 Enuum for side of the Illiac-Acetabular Bone view for each 2D Frame 41 """ 42 43 LEFT = "illiac wing" 44 RIGHT = "acetabular roof"
Enuum for side of the Illiac-Acetabular Bone view for each 2D Frame
LEFT =
<SideZ.LEFT: 'illiac wing'>
RIGHT =
<SideZ.RIGHT: 'acetabular roof'>
Inherited Members
- enum.Enum
- name
- value
ACA_COLORS =
{(<SideZ.LEFT: 'illiac wing'>, <Side.POST: 1>): 'red', (<SideZ.LEFT: 'illiac wing'>, <Side.GRAF: 2>): 'orange', (<SideZ.LEFT: 'illiac wing'>, <Side.ANT: 0>): 'yellow', (<SideZ.RIGHT: 'acetabular roof'>, <Side.POST: 1>): 'blue', (<SideZ.RIGHT: 'acetabular roof'>, <Side.GRAF: 2>): 'cyan', (<SideZ.RIGHT: 'acetabular roof'>, <Side.ANT: 0>): 'lightgreen'}
class
Triangle:
60class Triangle: 61 """ 62 Class to store the data of a triangle for the ACA calculation. 63 64 :attr apex_side: SideZ: The side of the Illium that the apex is on. 65 :attr third: Side: The third of the Illium that the triangle is in. 66 :attr normal: NDArray[np.float64]: The normal of the triangle. 67 :attr centroid: NDArray[np.float64]: The centroid of the triangle. 68 :attr color: str: The color of the triangle. 69 """ 70 71 def __init__( 72 self, 73 apex_side: SideZ, 74 third: Side, 75 normal: NDArray[np.float64], 76 centroid: NDArray[np.float64], 77 ): 78 self.apex_side = apex_side 79 self.third = third 80 self.normal = normal 81 self.centroid = centroid 82 self.color = ACA_COLORS[(apex_side, third)]
Class to store the data of a triangle for the ACA calculation.
:attr apex_side: SideZ: The side of the Illium that the apex is on. :attr third: Side: The third of the Illium that the triangle is in. :attr normal: NDArray[np.float64]: The normal of the triangle. :attr centroid: NDArray[np.float64]: The centroid of the triangle. :attr color: str: The color of the triangle.
Triangle( apex_side: SideZ, third: retuve.hip_us.classes.enums.Side, normal: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]], centroid: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]])
def
get_aca( illium_mesh: open3d.cpu.pybind.geometry.TriangleMesh, apex_points: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]], graf_hip: retuve.hip_us.classes.general.HipDataUS, no_of_frames: int, config: retuve.keyphrases.config.Config) -> Tuple[Dict[retuve.hip_us.classes.enums.Side, float], Tuple[numpy.ndarray[Any, numpy.dtype[numpy.float64]], numpy.ndarray[Any, numpy.dtype[numpy.float64]], str], List[Triangle], str]:
85def get_aca( 86 illium_mesh: o3d.geometry.TriangleMesh, 87 apex_points: NDArray[np.float64], 88 graf_hip: HipDataUS, 89 no_of_frames: int, 90 config: Config, 91) -> Tuple[ 92 Dict[Side, float], 93 Tuple[CoordinatesArray3D, CoordinatesArray3D, str], 94 List[Triangle], 95 str, 96]: 97 """ 98 Calculate the acetabular coverage angle (ACA) for the hip. 99 100 :param illium_mesh (o3d.geometry.TriangleMesh): The Illium Mesh. 101 :param apex_points (NDArray[np.float64]): The apex points of the Illium. 102 :param graf_hip (HipDataUS): The Hip Data of the Graf Frame. 103 :param no_of_frames (int): The number of frames in the 3D Ultrasound. 104 :param config (Config): The Config object. 105 106 :return: Tuple of the ACA angles for each side, the average normals data, 107 the triangle data, and the recorded error. 108 """ 109 z_gap = config.hip.z_gap * (200 / no_of_frames) 110 111 # add normals as arrows 112 illium_mesh.compute_vertex_normals() 113 normals = np.asarray(illium_mesh.vertex_normals) 114 vertices = np.asarray(illium_mesh.vertices) 115 116 # Compute triangle normals 117 triangle_normals = [] 118 triangle_centroids = [] 119 for tri in illium_mesh.triangles: 120 v0, v1, v2 = vertices[tri] 121 edge1 = v1 - v0 122 edge2 = v2 - v0 123 normal = np.cross(edge1, edge2) 124 centroid = (v0 + v1 + v2) / 3 125 triangle_normals.append(normal) 126 triangle_centroids.append(centroid) 127 128 triangle_normals = np.array(triangle_normals) 129 triangle_centroids = np.array(triangle_centroids) 130 131 # convert normals to unit vectors 132 triangle_normals /= np.linalg.norm(triangle_normals, axis=1)[:, None] 133 134 triangle_data = [] 135 136 max_z = np.max(triangle_centroids[:, 2]) 137 min_z = np.min(triangle_centroids[:, 2]) 138 139 for normal, centroid in zip(triangle_normals, triangle_centroids): 140 if config.hip.aca_split == ACASplit.THIRDS: 141 third_z = (max_z - min_z) / 3 142 143 # find which third the centroid is in 144 if centroid[2] - min_z < third_z: 145 third = Side.POST 146 elif centroid[2] - min_z < third_z * 2: 147 third = Side.GRAF 148 else: 149 third = Side.ANT 150 151 elif config.hip.aca_split == ACASplit.GRAFS: 152 grafs_z = graf_hip.frame_no * z_gap 153 154 grafs_z = grafs_z 155 156 margin = (max_z - min_z) * 0.15 157 158 min_graf_z = grafs_z - margin 159 160 # if it is within 10% of the grafs_z, third = 2 161 if abs(centroid[2] - grafs_z) < margin: 162 third = Side.GRAF 163 elif centroid[2] < min_graf_z: 164 third = Side.POST 165 else: 166 third = Side.ANT 167 168 else: 169 raise ValueError("Invalid ACA Split Config Option") 170 171 apex_points = copy.deepcopy(np.array(apex_points)) 172 173 # get the apex point with the closest z value 174 apex_point = apex_points[ 175 np.argmin(np.abs(apex_points[:, 2] - centroid[2])) 176 ] 177 178 if centroid[0] < apex_point[0]: 179 apex_side = SideZ.LEFT 180 else: 181 apex_side = SideZ.RIGHT 182 183 triangle_data.append( 184 Triangle( 185 apex_side, 186 third, 187 normal, 188 centroid, 189 ) 190 ) 191 192 final_vectors = { 193 # (left, 1): vec 194 } 195 196 avg_normals_data = [] 197 198 # for each color combination, find the average normal and add a cone for it 199 for apex_side in [SideZ.LEFT, SideZ.RIGHT]: 200 for third in Side.ALL(): 201 normals = [] 202 centroids = [] 203 204 for triangle in triangle_data: 205 if triangle.apex_side == apex_side and triangle.third == third: 206 normals.append(triangle.normal) 207 centroids.append(triangle.centroid) 208 209 # This error is recorded in other places. 210 # And will happen when the grafs plane is detected 211 # At the edges of the measured region 212 if len(normals) == 0: 213 continue 214 215 normals = np.array(normals) 216 centroids = np.array(centroids) 217 218 avg_normal = np.mean(normals, axis=0) 219 avg_centroid = np.mean(centroids, axis=0) 220 221 avg_normals_data.append( 222 ( 223 avg_normal, 224 avg_centroid, 225 ACA_COLORS[(apex_side, third)], 226 ) 227 ) 228 229 final_vectors[apex_side, third] = avg_normal 230 231 aca_angles = {} 232 233 # for each third, find the angle between the apex left and right 234 for side in Side.ALL(): 235 try: 236 apex_left = final_vectors[SideZ.LEFT, side] 237 apex_right = final_vectors[SideZ.RIGHT, side] 238 recorded_error = "" 239 except KeyError: 240 aca_angles[side] = 0 241 recorded_error = ( 242 f"Not enough {Side.get_name(side)} to calculate ACA." 243 ) 244 continue 245 246 # get the angle between the two apex points 247 angle = np.arccos( 248 np.dot(apex_left, apex_right) 249 / (np.linalg.norm(apex_left) * np.linalg.norm(apex_right)) 250 ) 251 252 # convert to degrees 253 angle = np.degrees(angle) 254 255 aca_angles[side] = round(angle, 2) 256 257 return aca_angles, avg_normals_data, triangle_data, recorded_error
Calculate the acetabular coverage angle (ACA) for the hip.
Parameters
- illium_mesh (o3d.geometry.TriangleMesh): The Illium Mesh.
- apex_points (NDArray[np.float64]): The apex points of the Illium.
- graf_hip (HipDataUS): The Hip Data of the Graf Frame.
- no_of_frames (int): The number of frames in the 3D Ultrasound.
- config (Config): The Config object.
Returns
Tuple of the ACA angles for each side, the average normals data, the triangle data, and the recorded error.