|
| 1 | +"""Create a surface mesh by morphing a template mesh and landmarks |
| 2 | +to a new set of landmarks. |
| 3 | +This is done using a radial basis function with a gaussian kernel. |
| 4 | +Source: Grassi et al. (2011) Medical Engineering & Physics. |
| 5 | +""" |
| 6 | +import numpy as np |
| 7 | +import trimesh |
| 8 | +import vedo as v |
| 9 | +from . import utils |
| 10 | + |
| 11 | +__all__ = ["MorphTemplateMesh"] |
| 12 | + |
| 13 | +class MorphTemplateMesh: |
| 14 | + """Create a mesh for a new set of landmarks based on a pre-existing 'template' mesh, |
| 15 | + for which we already have a set of landmarks. |
| 16 | + This is done using a radial basis function with a gaussian kernel. |
| 17 | + Source: Grassi et al. (2011) Medical Engineering & Physics. |
| 18 | + |
| 19 | + Note that this requires the meshes are already aligned. |
| 20 | + In rare cases, the error may be high. In this case, I would suggest trying a template |
| 21 | + that is closer to the target if available. |
| 22 | +
|
| 23 | + Examples |
| 24 | + ======== |
| 25 | + >>> import pyssam |
| 26 | + >>> torus = pyssam.datasets.Torus() |
| 27 | + >>> torus_mesh_list = torus.make_dataset(2) |
| 28 | + >>> landmark_coordinates = [sample_i.points()[::10] for sample_i in torus_mesh_list] |
| 29 | + >>> mesh_target_actual = torus_mesh_list[-1] |
| 30 | + >>> mesh_target_computed = pyssam.morph_mesh.MorphTemplateMesh( |
| 31 | + landmark_target=landmark_coordinates[-1], |
| 32 | + landmark_template=landmark_coordinates[0], |
| 33 | + mesh_template=torus_mesh_list[0] |
| 34 | + ).mesh_target |
| 35 | + >>> volume_error = 100.0 * abs(mesh_target_computed.volume() - mesh_target_actual.volume()) / mesh_target_actual.volume() |
| 36 | + >>> print("volume error below 5%?", volume_error < 5.0) |
| 37 | + True |
| 38 | + """ |
| 39 | + def __init__( |
| 40 | + self, |
| 41 | + landmark_target, |
| 42 | + landmark_template, |
| 43 | + mesh_template, |
| 44 | + # kernel="gaussian", |
| 45 | + kernel_width=0.3, |
| 46 | + smooth=False, |
| 47 | + ): |
| 48 | + |
| 49 | + self.smooth = smooth |
| 50 | + kernel = "gaussian" # only one implemented currently |
| 51 | + # gaussian kernel width |
| 52 | + self.kernel_width = kernel_width |
| 53 | + # select functions based on chosen morphing kernel |
| 54 | + if kernel == "gaussian": |
| 55 | + self.kernel_function = self.gaussian_kernel |
| 56 | + else: |
| 57 | + raise NotImplementedError( |
| 58 | + f"The kernel type '{kernel}' has not been implemented." |
| 59 | + ) |
| 60 | + self.mesh_template = mesh_template |
| 61 | + |
| 62 | + ( |
| 63 | + self.landmark_target, |
| 64 | + self.landmark_template, |
| 65 | + self.coords_template, |
| 66 | + self.std_scale, |
| 67 | + ) = self.scale_and_align_coordinates( |
| 68 | + landmark_target.copy(), landmark_template.copy(), mesh_template.points() |
| 69 | + ) |
| 70 | + self.do_mesh_morphing() |
| 71 | + |
| 72 | + def do_mesh_morphing(self): |
| 73 | + """Compute coordinates of vertices on new mesh (corresponding to landmark_target). |
| 74 | + The new coordinates are then used to create a new mesh, where face connectivity is the same |
| 75 | + as the template mesh. The new mesh is checked for watertightness, and some cleanup is done. |
| 76 | + """ |
| 77 | + # initialise coordinates of new mesh as a copy of the template mesh coordinates |
| 78 | + coords_new = self.coords_template.copy() |
| 79 | + # morph template surface coordinates |
| 80 | + for i, coords_template_i in enumerate(self.coords_template): |
| 81 | + w = self.get_weights( |
| 82 | + self.landmark_target, self.landmark_template, coords_template_i, self.kernel_function |
| 83 | + ) |
| 84 | + kernel_output = self.kernel_function(self.landmark_template, coords_template_i)[:, np.newaxis] |
| 85 | + coords_new[i] = coords_template_i + np.sum(kernel_output * w, axis=0) |
| 86 | + |
| 87 | + # rescale from standard deviation normalised values to real-world |
| 88 | + coords_new *= self.std_scale |
| 89 | + |
| 90 | + self.mesh_target = self.create_new_mesh(coords_new) |
| 91 | + # self.mesh_target = self.clean_new_mesh(self.mesh_target) |
| 92 | + return self.mesh_target |
| 93 | + |
| 94 | + def create_new_mesh(self, coords): |
| 95 | + """Create a mesh from a set of coordinates, and faces of the template mesh. |
| 96 | + |
| 97 | + Parameters |
| 98 | + ---------- |
| 99 | + coords : array_like |
| 100 | + A set of coordinates corresponding to vertices on a new mesh. Ordering must be |
| 101 | + consistent with self.mesh_template.points() |
| 102 | +
|
| 103 | + Returns |
| 104 | + ------- |
| 105 | + mesh : vedo.Mesh |
| 106 | + vedo object containing coordinates and face connectivity for new surface mesh |
| 107 | + """ |
| 108 | + # create mesh object from morphed vertices |
| 109 | + return v.Mesh([coords, self.mesh_template.faces()]) |
| 110 | + |
| 111 | + def clean_new_mesh(self, mesh_target): |
| 112 | + """Use trimesh functionality to check mesh is watertight and do some cleaning |
| 113 | + operations and smoothing if desired. |
| 114 | + |
| 115 | + Parameters |
| 116 | + ---------- |
| 117 | + mesh_target : vedo.Mesh |
| 118 | + vedo object containing coordinates and face connectivity for new surface mesh |
| 119 | +
|
| 120 | + Returns |
| 121 | + ------- |
| 122 | + mesh_target : vedo.Mesh |
| 123 | + vedo object containing coordinates and face connectivity for new surface mesh |
| 124 | + """ |
| 125 | + # smoothing and clean up |
| 126 | + mesh_targettri = mesh_target.to_trimesh() |
| 127 | + watertight = mesh_targettri.is_watertight |
| 128 | + if not watertight: |
| 129 | + print("Watertight mesh?", watertight) |
| 130 | + trimesh.repair.fill_holes(mesh_targettri) |
| 131 | + trimesh.repair.broken_faces(mesh_targettri) |
| 132 | + |
| 133 | + if self.smooth: |
| 134 | + trimesh.smoothing.filter_humphrey(mesh_targettri, alpha=0.1) |
| 135 | + trimesh.smoothing.filter_humphrey(mesh_targettri, alpha=0.1) |
| 136 | + watertight = mesh_targettri.is_watertight |
| 137 | + |
| 138 | + if not watertight: |
| 139 | + trimesh.repair.fill_holes(mesh_targettri) |
| 140 | + trimesh.repair.broken_faces(mesh_targettri) |
| 141 | + return v.trimesh2vedo(mesh_targettri) |
| 142 | + |
| 143 | + def scale_and_align_coordinates( |
| 144 | + self, landmark_target, landmark_template, coords_template |
| 145 | + ): |
| 146 | + """Scale the template landmarks and mesh coordinates to the same size as the |
| 147 | + target landmarks. |
| 148 | +
|
| 149 | + Parameters |
| 150 | + ---------- |
| 151 | + landmark_target : array_like |
| 152 | + Landmarks of the new shape which we want to morph the mesh to, shape (N,3). |
| 153 | +
|
| 154 | + landmark_template : array_like |
| 155 | + Landmarks of the template shape (which we already have a mesh for), shape (N,3). |
| 156 | +
|
| 157 | + template_coords_i : array_like |
| 158 | + coordinates on the template surface mesh. |
| 159 | +
|
| 160 | + Returns |
| 161 | + ------- |
| 162 | + landmark_target : array_like |
| 163 | + Landmarks of the new shape which we want to morph the mesh to, scaled to 1 std-dev |
| 164 | +
|
| 165 | + landmark_template : array_like |
| 166 | + Landmarks of the template shape, scaled to same scale as target landmarks. |
| 167 | +
|
| 168 | + template_coords_i : array_like |
| 169 | + coordinates on the template surface mesh, scaled to same scale as target landmarks. |
| 170 | + """ |
| 171 | + # scale and align template mesh and landmarks |
| 172 | + size_target = landmark_target.max(axis=0) - landmark_target.min(axis=0) |
| 173 | + size_template = landmark_template.max(axis=0) - landmark_template.min( |
| 174 | + axis=0 |
| 175 | + ) |
| 176 | + size_ratio = size_target / size_template |
| 177 | + coords_template *= size_ratio |
| 178 | + landmark_template *= size_ratio |
| 179 | + coords_new = coords_template.copy() |
| 180 | + |
| 181 | + # scale to unit standard deviation, such that the gaussian filter is consistent |
| 182 | + # for shapes of different sizes |
| 183 | + std_scale = coords_new.std(axis=0) |
| 184 | + coords_new /= std_scale |
| 185 | + coords_template /= std_scale |
| 186 | + landmark_template /= std_scale |
| 187 | + landmark_target /= std_scale |
| 188 | + |
| 189 | + return landmark_target, landmark_template, coords_template, std_scale |
| 190 | + |
| 191 | + def gaussian_kernel(self, landmark_template, template_coords_i): |
| 192 | + """Function to find distance between a coordinate and all surrounding landmarks. |
| 193 | + We use a Gaussian kernel to smooth how the surrounding landmarks act on the point. |
| 194 | +
|
| 195 | + Parameters |
| 196 | + ---------- |
| 197 | + landmark_template : array_like |
| 198 | + Landmarks of the template shape (which we already have a mesh for), shape (N,3). |
| 199 | +
|
| 200 | + template_coords_i : array_like |
| 201 | + coordinates on the template surface mesh, shape (3). |
| 202 | +
|
| 203 | + Returns |
| 204 | + ------- |
| 205 | + distances : array_like |
| 206 | + Scalar value of distances between all landmarks and the template mesh coordinate. |
| 207 | + """ |
| 208 | + return np.exp( |
| 209 | + -(utils.euclidean_distance(landmark_template, template_coords_i) ** 2.0) |
| 210 | + / (2.0 * self.kernel_width**2) |
| 211 | + ) |
| 212 | + |
| 213 | + def get_weights( |
| 214 | + self, landmark_target, landmark_template, template_coords_i, kernel_function |
| 215 | + ): |
| 216 | + """Find weight coefficients that control how the template mesh is morphed to |
| 217 | + the new geometry, based on the distance between the 'template' and 'target' landmarks |
| 218 | + (which are in correspondence, so the ordering is consistent). |
| 219 | +
|
| 220 | + For more information, see equations (1) and (2) in |
| 221 | + Grassi et al. (2011) (Medical Engineering & Physics). |
| 222 | +
|
| 223 | + Parameters |
| 224 | + ---------- |
| 225 | + landmark_target : array_like |
| 226 | + Landmarks of the new shape which we want to morph the mesh to, shape (N,3). |
| 227 | +
|
| 228 | + landmark_template : array_like |
| 229 | + Landmarks of the template shape (which we already have a mesh for), shape (N,3). |
| 230 | +
|
| 231 | + template_coords_i : array_like |
| 232 | + coordinates on the template surface mesh. |
| 233 | +
|
| 234 | + Returns |
| 235 | + ------- |
| 236 | + weights : array_like |
| 237 | + Weights are coefficients that control how strongly the kernel effects each point. |
| 238 | + """ |
| 239 | + kernel_output = kernel_function(landmark_template, template_coords_i) |
| 240 | + weights = (landmark_target - landmark_template) / kernel_output.sum() |
| 241 | + return weights |
| 242 | + |
0 commit comments