Skip to content

Commit 20a4e6c

Browse files
authored
Merge pull request #7 from jvwilliams23/feature_meshmorph
Add mesh-morphing feature
2 parents 7056ef4 + 704e3c9 commit 20a4e6c

14 files changed

+848
-7
lines changed

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ statistical shape and/or appearance modelling.
5050
tutorial/ssm_example.ipynb
5151
tutorial/sam_example.ipynb
5252
tutorial/ssam_example.ipynb
53+
tutorial/mesh_morphing_example.ipynb
5354

5455
Extending to new datasets
5556
-------------------------

docs/pyssam.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ pyssam.utils module
5252
:undoc-members:
5353
:show-inheritance:
5454

55+
pyssam.morph\_mesh module
56+
-------------------
57+
58+
.. automodule:: pyssam.morph_mesh
59+
:members:
60+
:undoc-members:
61+
:show-inheritance:
62+
5563
Module contents
5664
---------------
5765

docs/requirements-docs.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ scikit_learn
55
scikit_image
66
networkx
77
vedo<=2023.4.6
8+
trimesh
89
sphinx == 7.2.6
910
sphinx-copybutton == 0.5.2
1011
sphinx-rtd-theme == 1.3.0

docs/tutorial/mesh_morphing_example.ipynb

Lines changed: 458 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"scikit_image==0.19.2",
2121
"networkx==2.5",
2222
"vedo<=2023.4.6",
23+
"trimesh"
2324
]
2425

2526
[tool.setuptools]

pyssam/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pyssam.ssm import SSM
44
from pyssam.sam import SAM
55
from pyssam.ssam import SSAM
6+
from pyssam import morph_mesh
67
from pyssam import datasets
78

8-
__all__ = ["SSM", "SAM", "SSAM", "datasets"]
9+
__all__ = ["SSM", "SAM", "SSAM", "datasets", "morph_mesh"]

pyssam/datasets.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
"""Some basic datasets for unit-testing and training examples"""
12
import networkx as nx
23
import numpy as np
34
import pyssam
45
from numpy import cos, sin
6+
import vedo as v
57

6-
__all__ = ["Tree"]
8+
__all__ = ["Tree", "Torus"]
79

810

911
class Tree:
@@ -183,3 +185,18 @@ def make_tree_landmarks(self) -> np.array:
183185
"""
184186
tree_graph = self.make_tree()
185187
return self.graph_to_coords(tree_graph)
188+
189+
class Torus:
190+
def __init__(self, inner_radius_mean=1, outer_radius_mean=2, inner_radius_std=0.3, outer_radius_std=0.15):
191+
self.inner_radius_mean = inner_radius_mean
192+
self.outer_radius_mean = outer_radius_mean
193+
self.inner_radius_std = inner_radius_std
194+
self.outer_radius_std = outer_radius_std
195+
196+
def make_dataset(self, n_samples):
197+
out_surfaces = []
198+
base_pos = np.array([0,0,0])
199+
for _ in range(n_samples):
200+
out_surfaces.append(v.Torus(pos=base_pos, r2=np.random.normal(self.inner_radius_mean, self.inner_radius_std), r1=np.random.normal(self.outer_radius_mean, self.outer_radius_std)))
201+
202+
return out_surfaces

pyssam/morph_mesh.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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+

tests/test_datasets.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,57 @@ def test_number_of_ends(self):
1515
num_ends == expected_ends
1616
), f"expected landmarks {expected_ends} != {num_ends}"
1717

18+
def test_torus_modes_normalised(self):
19+
N_SAMPLES = 100
20+
21+
torus = pyssam.datasets.Torus()
22+
data = torus.make_dataset(N_SAMPLES)
23+
24+
landmark_coordinates = np.array(
25+
[sample_i.points()[::10] for sample_i in data]
26+
)
27+
28+
# when landmarks are scaled, should only be one mode (inner/outer radius ratio)
29+
EXPECTED_NONZERO_MODES = 1
30+
ssm_obj = pyssam.SSM(landmark_coordinates)
31+
ssm_obj.create_pca_model(ssm_obj.landmarks_columns_scale)
32+
explained_variance = ssm_obj.pca_object.explained_variance_ratio_
33+
num_nonzero_modes = (explained_variance > 0.1).sum()
34+
assert (
35+
num_nonzero_modes == EXPECTED_NONZERO_MODES
36+
), f"num non-zero modes {num_nonzero_modes} but expected {EXPECTED_NONZERO_MODES} for variances {explained_variance}"
37+
38+
def test_torus_modes_not_normalised(self):
39+
N_SAMPLES = 100
40+
41+
torus = pyssam.datasets.Torus()
42+
data = torus.make_dataset(N_SAMPLES)
43+
44+
landmark_coordinates = np.array(
45+
[sample_i.points()[::10] for sample_i in data]
46+
)
47+
48+
# when landmarks are not scaled, should be two modes (inner radius, outer radius)
49+
EXPECTED_NONZERO_MODES = 2
50+
ssm_obj = pyssam.SSM(landmark_coordinates)
51+
ssm_obj.create_pca_model(landmark_coordinates.reshape(N_SAMPLES, -1))
52+
explained_variance = ssm_obj.pca_object.explained_variance_ratio_
53+
num_nonzero_modes = (explained_variance > 0.1).sum()
54+
assert (
55+
num_nonzero_modes == EXPECTED_NONZERO_MODES
56+
), f"num non-zero modes {num_nonzero_modes} but expected {EXPECTED_NONZERO_MODES} for variances {explained_variance}"
57+
58+
def test_torus_to_points(self):
59+
N_SAMPLES = 2
60+
61+
torus = pyssam.datasets.Torus()
62+
data = torus.make_dataset(N_SAMPLES)
63+
64+
# landmark_coordinates = np.array(
65+
# [sample_i.points()[::10] for sample_i in data]
66+
# )
67+
assert type(data[0].points()) == np.ndarray, f"torus.points() type np.ndarray != {type(data[0].points())}"
68+
1869

1970
if __name__ == "__main__":
2071
unittest.main()

0 commit comments

Comments
 (0)