alignfaces 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alignfaces/__init__.py +15 -0
- alignfaces/aperture_tools.py +213 -0
- alignfaces/contrast_tools.py +106 -0
- alignfaces/contrast_tools_.py +106 -0
- alignfaces/data/shape_predictor_68_face_landmarks.dat +0 -0
- alignfaces/face_landmarks.py +233 -0
- alignfaces/make_aligned_faces.py +1217 -0
- alignfaces/make_aligned_faces_.py +1209 -0
- alignfaces/make_files.py +42 -0
- alignfaces/make_files_.py +42 -0
- alignfaces/make_files_OLD.py +86 -0
- alignfaces/phase_cong_3.py +524 -0
- alignfaces/plot_tools.py +170 -0
- alignfaces/procrustes_tools.py +217 -0
- alignfaces/tests/R/align_reference.csv +1 -0
- alignfaces/tests/R/align_shapes.csv +40 -0
- alignfaces/tests/R/input_shapes.csv +40 -0
- alignfaces/tests/__init__.py +0 -0
- alignfaces/tests/_test_pawarp.py +267 -0
- alignfaces/tests/test_procrustes_tools.py +569 -0
- alignfaces/tests/test_warp_tools.py +316 -0
- alignfaces/warp_tools.py +279 -0
- alignfaces-1.0.1.dist-info/METADATA +135 -0
- alignfaces-1.0.1.dist-info/RECORD +27 -0
- alignfaces-1.0.1.dist-info/WHEEL +5 -0
- alignfaces-1.0.1.dist-info/licenses/LICENSE.txt +13 -0
- alignfaces-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
CAVEATS
|
|
4
|
+
Any transform requiring a rotation > abs(pi / 2) will be poor.
|
|
5
|
+
The further past this threshold, the greater the error.
|
|
6
|
+
Domain knowledge for faces allows us to perform a pre-processing step to
|
|
7
|
+
circumvent this limitation by simply re-orienting any faces with a
|
|
8
|
+
specific landmark configuration suggesting > abs(pi/2) from upright.
|
|
9
|
+
|
|
10
|
+
When I am done here,
|
|
11
|
+
Only change necessary is "import procrustes_tools as pt" to
|
|
12
|
+
"import alignfaces2.procrustes_tools as pt"
|
|
13
|
+
|
|
14
|
+
TO DO
|
|
15
|
+
|
|
16
|
+
1. make_aligned_faces module or elsewhere -- pre-processing reorient outliers.
|
|
17
|
+
* because of max rotation issue
|
|
18
|
+
"""
|
|
19
|
+
#################################################################
|
|
20
|
+
# IMPORT MODULES
|
|
21
|
+
#################################################################
|
|
22
|
+
from importlib.resources import files
|
|
23
|
+
|
|
24
|
+
# import sys
|
|
25
|
+
import numpy as np
|
|
26
|
+
# import matplotlib.pyplot as plt
|
|
27
|
+
# import pdb # TEMPORARY FOR DEBUGGING
|
|
28
|
+
import alignfaces.procrustes_tools as pt
|
|
29
|
+
# import alignfaces2.procrustes_toolsRohlf as pt
|
|
30
|
+
|
|
31
|
+
#################################################################
|
|
32
|
+
# TEMPORARY TO RUN WITHOUT INSTALLATION OF ALIGNFACES2.
|
|
33
|
+
#################################################################
|
|
34
|
+
# try:
|
|
35
|
+
# arg = sys.argv[1]
|
|
36
|
+
# except IndexError:
|
|
37
|
+
# raise SystemExit(f"Usage: {sys.argv[0]} <kravchenko or rohlf>")
|
|
38
|
+
#
|
|
39
|
+
# package_dir = "/Users/Carl/Studies/facepackage/alignfaces2/src/alignfaces2"
|
|
40
|
+
# sys.path.append(package_dir)
|
|
41
|
+
#
|
|
42
|
+
# if arg == "kravchenko":
|
|
43
|
+
# import procrustes_tools as pt
|
|
44
|
+
# else:
|
|
45
|
+
# import procrustes_toolsRohlf as pt
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#################################################################
|
|
49
|
+
# DEFINE SUBFUNCTIONS USED FOR UNIT TESTING
|
|
50
|
+
#################################################################
|
|
51
|
+
def matrix_to_list_of_vectors(M):
|
|
52
|
+
"""
|
|
53
|
+
Convert npy matrix of shapes (shapes are rows) to a list of shapes.
|
|
54
|
+
Entries in list are one-dimensional npy arrays.
|
|
55
|
+
|
|
56
|
+
M is typical output of csv import.
|
|
57
|
+
Output list is what's required of procrustes_tools module;
|
|
58
|
+
specifically, generalized_procrustes_analysis().
|
|
59
|
+
"""
|
|
60
|
+
M = M.tolist()
|
|
61
|
+
L = [np.array(s) for s in M]
|
|
62
|
+
return L
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def max_abs_error_relative_to_mean_radius(standard_shape, test_shape):
|
|
66
|
+
"""
|
|
67
|
+
Compare test_shape to standard_shape, relative to mean radius of standard.
|
|
68
|
+
Returned value is a percentage.
|
|
69
|
+
|
|
70
|
+
standard_shape npy array
|
|
71
|
+
test_shape npy array
|
|
72
|
+
|
|
73
|
+
Must have the same shape. Either:
|
|
74
|
+
one-dimensional (single shape) or
|
|
75
|
+
two-dimensional (shapes along first dimension)
|
|
76
|
+
|
|
77
|
+
Each shape is [x1 y1 x2 y2 ... xp yp] for p points.
|
|
78
|
+
"""
|
|
79
|
+
assert standard_shape.shape == test_shape.shape
|
|
80
|
+
base_mat = standard_shape.reshape(-1, 2)
|
|
81
|
+
base_radii = np.sqrt(np.dot(base_mat, base_mat.T) *
|
|
82
|
+
np.eye(base_mat.shape[0]))
|
|
83
|
+
mean_radius_base = np.trace(base_radii) / base_mat.shape[0]
|
|
84
|
+
|
|
85
|
+
max_abs_error = np.abs(standard_shape - test_shape).max()
|
|
86
|
+
error_percent = max_abs_error * 100 / mean_radius_base
|
|
87
|
+
return error_percent
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _affine_perturbation(base_shape, number_samples=2, kappa=10):
|
|
91
|
+
# Center to 0.
|
|
92
|
+
mean_x, mean_y = np.mean(base_shape[::2]), np.mean(base_shape[1::2])
|
|
93
|
+
# centered = np.copy(base_shape)
|
|
94
|
+
centered = base_shape.copy()
|
|
95
|
+
centered[::2] -= mean_x
|
|
96
|
+
centered[1::2] -= mean_y
|
|
97
|
+
base_points = np.tile(centered, (number_samples, 1))
|
|
98
|
+
|
|
99
|
+
# Random rotation & scale.
|
|
100
|
+
thetas, scales = [], []
|
|
101
|
+
temp_shape = centered.reshape((-1, 2)).T
|
|
102
|
+
for i in range(number_samples):
|
|
103
|
+
# Make affine transformation matrix.
|
|
104
|
+
# theta = np.random.vonmises(mu=0, kappa=kappa)
|
|
105
|
+
theta = np.random.random_sample() * (np.pi / 2) # [0, pi/2)
|
|
106
|
+
# multiply from [-1 or 1]
|
|
107
|
+
# isneg = (np.random.random() > 0.5)
|
|
108
|
+
# theta = (-1 * isneg * theta) + (isneg==False) * theta # (-pi/2, pi/2)
|
|
109
|
+
scale = np.random.lognormal(mean=0.0, sigma=0.25)
|
|
110
|
+
thetas.append(theta)
|
|
111
|
+
scales.append(scale)
|
|
112
|
+
scaled_shape = temp_shape * scale
|
|
113
|
+
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)],
|
|
114
|
+
[np.sin(theta), np.cos(theta)]])
|
|
115
|
+
|
|
116
|
+
# Apply transform.
|
|
117
|
+
rotated_shape = np.dot(rotation_matrix, scaled_shape)
|
|
118
|
+
base_points[i, :] = rotated_shape.T.reshape(-1)
|
|
119
|
+
|
|
120
|
+
# Random translation.
|
|
121
|
+
shift_shapes = np.random.randint(low=0, high=10, size=(number_samples, 2))
|
|
122
|
+
shift_points = np.tile(shift_shapes, (1, int(centered.size / 2)))
|
|
123
|
+
shapes = base_points + shift_points
|
|
124
|
+
|
|
125
|
+
# Format of shapes in Procrustes Tools is list of numpy arrays.
|
|
126
|
+
shape_list = []
|
|
127
|
+
for i in range(number_samples):
|
|
128
|
+
shape_list.append(shapes[i, :])
|
|
129
|
+
return {"shape_list": shape_list, "thetas": thetas,
|
|
130
|
+
"scales": scales, "shift_points": shift_points,
|
|
131
|
+
"meanxy": [mean_x, mean_y]}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _affine_recovery(shapes, thetas, scales, shift_points):
|
|
135
|
+
"""
|
|
136
|
+
Get recovered_shapes from shapes and parameters for original affine
|
|
137
|
+
transformation.
|
|
138
|
+
|
|
139
|
+
Each should be very close to base_shape
|
|
140
|
+
Can then measure average distance between base_shape and each of shapes.
|
|
141
|
+
|
|
142
|
+
But for a fair comparison with GPA output, we can also compare between the
|
|
143
|
+
recovered shapes themselves.
|
|
144
|
+
"""
|
|
145
|
+
# Set up.
|
|
146
|
+
shapes_array = np.array(shapes)
|
|
147
|
+
num_shapes = shapes_array.shape[0]
|
|
148
|
+
num_points = int(shapes_array.shape[1] / 2)
|
|
149
|
+
|
|
150
|
+
# Undo translation.
|
|
151
|
+
shifted_shapes = shapes_array - shift_points
|
|
152
|
+
|
|
153
|
+
# Undo rotation & scale.
|
|
154
|
+
recovered_points = np.zeros(shapes_array.shape)
|
|
155
|
+
for i in range(num_shapes):
|
|
156
|
+
theta = -thetas[i] # HERE
|
|
157
|
+
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)],
|
|
158
|
+
[np.sin(theta), np.cos(theta)]])
|
|
159
|
+
|
|
160
|
+
temp_shape = shifted_shapes[i, :].reshape((-1, 2)).T
|
|
161
|
+
rotated_shape = np.dot(rotation_matrix, temp_shape)
|
|
162
|
+
scaled_shape = rotated_shape / scales[i]
|
|
163
|
+
recovered_points[i, :] = scaled_shape.T.reshape((1, num_points * 2))
|
|
164
|
+
recovered_points[:, ::2] += mean_x
|
|
165
|
+
recovered_points[:, 1::2] += mean_y
|
|
166
|
+
return recovered_points
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _make_shapes_around_base(base_shape, num_pairs=10):
|
|
170
|
+
"""
|
|
171
|
+
Randomly generate pairs of shapes that are perturbations of base_shape.
|
|
172
|
+
Each shape is slightly different from base_shape.
|
|
173
|
+
However, each pair of shapes has a mean equal to base_shape.
|
|
174
|
+
Further, all shapes are centered at origin.
|
|
175
|
+
"""
|
|
176
|
+
assert np.isclose(base_shape[::2].mean(), 0)
|
|
177
|
+
assert np.isclose(base_shape[1::2].mean(), 0)
|
|
178
|
+
shapes = []
|
|
179
|
+
for i in range(num_pairs):
|
|
180
|
+
# Sample perturbations to first 2 points.
|
|
181
|
+
# Independence of samples means that many resulting shapes will be
|
|
182
|
+
# 'different' from base_shape; i.e., affine transform insufficient to
|
|
183
|
+
# match base_shape.
|
|
184
|
+
first_four = np.random.normal(loc=0, scale=2, size=(4,))
|
|
185
|
+
# Final point ensures that resulting shape is centered at origin.
|
|
186
|
+
next_one = np.array([-first_four[0] - first_four[2]])
|
|
187
|
+
last_one = np.array([-first_four[1] - first_four[3]])
|
|
188
|
+
# Vector of perturbations.
|
|
189
|
+
delta = np.r_[first_four, next_one, last_one]
|
|
190
|
+
# First shape in this pair is a perturbation of base_shape.
|
|
191
|
+
shape1 = base_shape + delta
|
|
192
|
+
# Opposite perturbation to second shape in this pair.
|
|
193
|
+
# Ensures mean of this pair is almost equivalent to base_shape.
|
|
194
|
+
shape2 = base_shape - delta
|
|
195
|
+
# Checks to ensure what was claimed is true.
|
|
196
|
+
assert np.isclose(shape1[::2].mean(), 0)
|
|
197
|
+
assert np.isclose(shape1[1::2].mean(), 0)
|
|
198
|
+
assert np.isclose(shape2[::2].mean(), 0)
|
|
199
|
+
assert np.isclose(shape2[1::2].mean(), 0)
|
|
200
|
+
assert np.allclose((shape1 + shape2) / 2, base_shape)
|
|
201
|
+
shapes.append(shape1)
|
|
202
|
+
shapes.append(shape2)
|
|
203
|
+
mean_sampled = np.array(shapes).mean(axis=0)
|
|
204
|
+
assert np.allclose(mean_sampled, base_shape)
|
|
205
|
+
return shapes
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# def plot_shapes(shapes):
|
|
209
|
+
# assert type(shapes) is list
|
|
210
|
+
# assert type(shapes[0]) is np.ndarray
|
|
211
|
+
# assert len(shapes[0].shape) == 1
|
|
212
|
+
# shapes_npy = np.array(shapes)
|
|
213
|
+
# xx = np.transpose(shapes_npy[:, ::2])
|
|
214
|
+
# yy = np.transpose(shapes_npy[:, 1::2])
|
|
215
|
+
# fig, axs = plt.subplots()
|
|
216
|
+
# axs.plot(xx, yy)
|
|
217
|
+
# axs.axis('equal')
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def center_shapes_in_list(shapes):
|
|
221
|
+
return [pt.translate(s) for s in shapes]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def set_unit_energy(shapes):
|
|
225
|
+
if type(shapes) is list:
|
|
226
|
+
new_shapes = [z / np.sqrt(np.dot(z, z)) for z in shapes]
|
|
227
|
+
elif len(shapes.shape) == 2:
|
|
228
|
+
new_shapes = [z / np.sqrt(np.dot(z, z)) for z in shapes]
|
|
229
|
+
else:
|
|
230
|
+
new_shapes = shapes / np.sqrt(np.dot(shapes, shapes))
|
|
231
|
+
return new_shapes
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_angle_vector_3p(input_m):
|
|
235
|
+
m = pt._shape_array_to_matrix(input_m)
|
|
236
|
+
a = np.sqrt(((m[1] - m[2])**2).sum())
|
|
237
|
+
b = np.sqrt(((m[0] - m[2])**2).sum())
|
|
238
|
+
c = np.sqrt(((m[0] - m[1])**2).sum())
|
|
239
|
+
ra = np.arccos((b**2 + c**2 - a**2) / (2*b*c))
|
|
240
|
+
rb = np.arccos((a**2 + c**2 - b**2) / (2*a*c))
|
|
241
|
+
rc = np.arccos((a**2 + b**2 - c**2) / (2*a*b))
|
|
242
|
+
return np.array([ra, rb, rc]) * (180 / np.pi)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def all_comparison_triangles_isomorphic_with_standard(standard, comparison):
|
|
246
|
+
standard_angles = get_angle_vector_3p(standard)
|
|
247
|
+
A = [get_angle_vector_3p(P) for P in comparison]
|
|
248
|
+
matches_base = [np.allclose(a, standard_angles) for a in A]
|
|
249
|
+
return all(matches_base)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Does my affine sampler distort shape?
|
|
253
|
+
# This code only works with triangles.
|
|
254
|
+
base_shape = np.array([0, 0, 15, 10, 30, 5]).astype(float)
|
|
255
|
+
base_angles = get_angle_vector_3p(base_shape)
|
|
256
|
+
num_shapes = 1000
|
|
257
|
+
affine = _affine_perturbation(base_shape, number_samples=num_shapes)
|
|
258
|
+
shapes = affine['shape_list']
|
|
259
|
+
assert all_comparison_triangles_isomorphic_with_standard(base_shape, shapes)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
#################################################################
|
|
263
|
+
# IMPORT DATA
|
|
264
|
+
#################################################################
|
|
265
|
+
# Import geomorph plethodon data & results of geomorph's gpagen() in R.
|
|
266
|
+
def get_geomorph_plethodon_data():
|
|
267
|
+
in_shapes_file = files('alignfaces.tests.R').joinpath('input_shapes.csv')
|
|
268
|
+
al_shapes_file = files('alignfaces.tests.R').joinpath('align_shapes.csv')
|
|
269
|
+
al_ref_file = files('alignfaces.tests.R').joinpath('align_reference.csv')
|
|
270
|
+
shapes = np.loadtxt(fname=in_shapes_file, delimiter=',')
|
|
271
|
+
shapes = matrix_to_list_of_vectors(shapes)
|
|
272
|
+
aligned_R = np.loadtxt(fname=al_shapes_file, delimiter=',')
|
|
273
|
+
ref_R = np.loadtxt(fname=al_ref_file, delimiter=',')
|
|
274
|
+
cshapes = center_shapes_in_list(shapes)
|
|
275
|
+
return [cshapes, ref_R, aligned_R]
|
|
276
|
+
|
|
277
|
+
#################################################################
|
|
278
|
+
# DEFINE FUNCTIONS USED FOR UNIT TESTING
|
|
279
|
+
#################################################################
|
|
280
|
+
def test_translate_1():
|
|
281
|
+
"""Input shape is already centered at origin. No change expected."""
|
|
282
|
+
shape = np.array([-15, -5, 0, 5, 15, 0]).astype(float)
|
|
283
|
+
expected_shape = shape
|
|
284
|
+
new_shape = pt.translate(shape)
|
|
285
|
+
assert np.allclose(new_shape, expected_shape)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_translate_2():
|
|
289
|
+
"""Result of translation known."""
|
|
290
|
+
expected_shape = np.array([-15, -5, 0, 5, 15, 0]).astype(float)
|
|
291
|
+
shift = np.array([2, -6, 2, -6, 2, -6]).astype(float)
|
|
292
|
+
shape = expected_shape + shift
|
|
293
|
+
new_shape = pt.translate(shape)
|
|
294
|
+
assert np.allclose(new_shape, expected_shape)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_rotate_1():
|
|
298
|
+
"""Result of rotation known."""
|
|
299
|
+
shape = np.array([0, 0, 0, 1, 1, 0]).astype(float)
|
|
300
|
+
theta = np.pi / 2
|
|
301
|
+
expected_shape = np.array([0, 0, -1, 0, 0, 1]).astype(float)
|
|
302
|
+
new_shape = pt.rotate(shape, theta)
|
|
303
|
+
assert np.allclose(new_shape, expected_shape)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_rotate_2():
|
|
307
|
+
"""Result of rotation known. Just another theta."""
|
|
308
|
+
v = 1 / np.sqrt(2)
|
|
309
|
+
shape = np.array([0, 0, v, v, v, -v]).astype(float)
|
|
310
|
+
theta = np.pi / 4
|
|
311
|
+
expected_shape = np.array([0, 0, 0, 1, 1, 0]).astype(float)
|
|
312
|
+
new_shape = pt.rotate(shape, theta)
|
|
313
|
+
assert np.allclose(new_shape, expected_shape)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_theta_in_get_rotation_scale():
|
|
317
|
+
"""Optimal rotation known to be pi / 2."""
|
|
318
|
+
shape = np.array([0, 0, 0, 1, 1, 0]).astype(float)
|
|
319
|
+
reference_shape = np.array([0, 0, -1, 0, 0, 1]).astype(float)
|
|
320
|
+
scale, theta = pt.get_rotation_scale(reference_shape, shape)
|
|
321
|
+
assert np.isclose(theta, np.pi / 2)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_scale_in_get_rotation_scale():
|
|
325
|
+
"""Scale of shape relative to reference known to be 1 / 2."""
|
|
326
|
+
shape = np.array([0, 0, 0, 1, 1, 0]).astype(float)
|
|
327
|
+
reference_shape = np.array([0, 0, -1, 0, 0, 1]).astype(float) * 2
|
|
328
|
+
scale, theta = pt.get_rotation_scale(reference_shape, shape)
|
|
329
|
+
assert np.isclose(scale, 1/2)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_procrustes_analysis():
|
|
333
|
+
"""
|
|
334
|
+
Shape to be aligned to reference is an affine transform of reference.
|
|
335
|
+
Test function for classical procrustes analysis.
|
|
336
|
+
"""
|
|
337
|
+
reference_shape = np.array([0, 0, 0, 1, 1, 0]).astype(float) * 2
|
|
338
|
+
shape = np.array([0, 0, -1, 0, 0, 1]).astype(float)
|
|
339
|
+
aligned_shape = pt.procrustes_analysis(reference_shape, shape)
|
|
340
|
+
reference_shape = pt.translate(reference_shape)
|
|
341
|
+
aligned_shape = pt.translate(aligned_shape)
|
|
342
|
+
assert np.allclose(aligned_shape, reference_shape)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_generalized_procrustes_analysis_distortion():
|
|
346
|
+
# Does my GPA function distort shape?
|
|
347
|
+
base = np.array([0, 0, 15, 10, 30, 5]).astype(float)
|
|
348
|
+
affine = _affine_perturbation(base, number_samples=1000)
|
|
349
|
+
shapes = affine['shape_list']
|
|
350
|
+
|
|
351
|
+
mean_shape, new_shapes, D = pt.generalized_procrustes_analysis(shapes)
|
|
352
|
+
assert all_comparison_triangles_isomorphic_with_standard(base, new_shapes)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_generalized_procrustes_analysis_1():
|
|
356
|
+
"""
|
|
357
|
+
Shape to be aligned to reference is an affine transform of reference.
|
|
358
|
+
Test function for generalized procrustes analysis.
|
|
359
|
+
"""
|
|
360
|
+
shape1 = np.array([-3, -4, 3, -4, 0, 8]).astype(float)
|
|
361
|
+
shape2 = np.array([-4, 3, -4, -3, 8, 0]).astype(float)
|
|
362
|
+
shapes = [shape1, shape2]
|
|
363
|
+
expected_reference = shape1
|
|
364
|
+
reference_shape, new_shapes, D = pt.generalized_procrustes_analysis(shapes)
|
|
365
|
+
reference_shape = pt.procrustes_analysis(expected_reference,
|
|
366
|
+
reference_shape)
|
|
367
|
+
assert np.allclose(reference_shape, expected_reference)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_generalized_procrustes_analysis_equivalent_shapes():
|
|
371
|
+
# note: n should be > 3
|
|
372
|
+
"""
|
|
373
|
+
All triangles are affine isomorphic.
|
|
374
|
+
|
|
375
|
+
Any transform requiring a rotation > abs(pi / 2) will be poor.
|
|
376
|
+
The further past this threshold, the greater the error.
|
|
377
|
+
Domain knowledge for faces allows us to perform a pre-processing step to
|
|
378
|
+
circumvent this limitation by simply re-orienting any faces with a
|
|
379
|
+
specific landmark configuration suggesting > abs(pi/2) from upright.
|
|
380
|
+
|
|
381
|
+
To do
|
|
382
|
+
1. [here] clip sampled thetas in _affine_perturbation().
|
|
383
|
+
2. [overall] pre-processing. detect outliers via features and reorient.
|
|
384
|
+
"""
|
|
385
|
+
base_shape = np.array([0, 0, 15, 10, 30, 5]).astype(float)
|
|
386
|
+
# base_shape = pt.translate(base_shape)
|
|
387
|
+
|
|
388
|
+
num_shapes = 100
|
|
389
|
+
affine = _affine_perturbation(base_shape, number_samples=num_shapes)
|
|
390
|
+
shapes = affine['shape_list']
|
|
391
|
+
# shapes = center_shapes_in_list(shapes)
|
|
392
|
+
# pdb.set_trace() # TEMPORARY FOR DEBUGGING
|
|
393
|
+
mean_shape, new_shapes, D = pt.generalized_procrustes_analysis(shapes)
|
|
394
|
+
|
|
395
|
+
mean_distance_among_aligned = []
|
|
396
|
+
for i in range(num_shapes - 1):
|
|
397
|
+
for j in range(i + 1, num_shapes):
|
|
398
|
+
delta = new_shapes[i] - new_shapes[j]
|
|
399
|
+
d = np.sqrt(pt._shape_array_to_matrix(delta**2).sum(axis=1))
|
|
400
|
+
mean_distance_among_aligned.append(d.mean())
|
|
401
|
+
assert len(mean_distance_among_aligned) == (num_shapes**2-num_shapes)/2
|
|
402
|
+
|
|
403
|
+
base_mat = mean_shape.reshape(-1, 2)
|
|
404
|
+
base_radii = np.sqrt(np.dot(base_mat, base_mat.T) *
|
|
405
|
+
np.eye(base_mat.shape[0]))
|
|
406
|
+
mean_radius_base = np.trace(base_radii) / base_mat.shape[0]
|
|
407
|
+
# pdb.set_trace() # TEMPORARY FOR DEBUGGING
|
|
408
|
+
error_percent = max(mean_distance_among_aligned) * 100 / mean_radius_base
|
|
409
|
+
# print("Comparison of all aligned shapes generated from random affine "
|
|
410
|
+
# "transform of a base shape. Average point-wise distance for all"
|
|
411
|
+
# "pairwise comparisons of aligned shapes. Max of these relative"
|
|
412
|
+
# "to mean radius of mean aligned shape.")
|
|
413
|
+
# print(f"\tMax abs error as percent of mean radius of consensus\t: {error_percent}")
|
|
414
|
+
assert error_percent < 1e-10
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_generalized_procrustes_analysis_2():
|
|
418
|
+
"""
|
|
419
|
+
No affine transform to exactly match the 3rd shape to the rest.
|
|
420
|
+
However, each shape is an isosceles triangle.
|
|
421
|
+
Given that the procrustes algorithm initializes the reference shape as
|
|
422
|
+
the first shape in input list of shapes, final alignments should ensure
|
|
423
|
+
that the base of each triangle is horizontal and the 3rd point is centered
|
|
424
|
+
at zero along the x axis.
|
|
425
|
+
"""
|
|
426
|
+
shape1 = np.array([-3, -4, 3, -4, 0, 8]).astype(float)
|
|
427
|
+
shape2 = np.array([-4, 3, -4, -3, 8, 0]).astype(float)
|
|
428
|
+
shape3 = np.array([-3, -8, 3, -8, 0, 16]).astype(float)
|
|
429
|
+
shapes = [shape1, shape2, shape3]
|
|
430
|
+
reference_shape, new_shapes, D = pt.generalized_procrustes_analysis(shapes)
|
|
431
|
+
|
|
432
|
+
M = np.array(new_shapes)
|
|
433
|
+
first_2_pnts_horizontal = np.allclose(M[:, 1], M[:, 3])
|
|
434
|
+
third_pnt_at_zero_x = np.allclose(M[:, 4], np.array([0, 0, 0]))
|
|
435
|
+
assert (first_2_pnts_horizontal and third_pnt_at_zero_x)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_generalized_procrustes_analysis_geomorph_1():
|
|
439
|
+
geo_data = get_geomorph_plethodon_data()
|
|
440
|
+
shapes = geo_data[0]
|
|
441
|
+
ref_R = geo_data[1]
|
|
442
|
+
# Consensus shape & aligned shapes using this Python package.
|
|
443
|
+
ref_python, aligned_python, D = pt.generalized_procrustes_analysis(shapes)
|
|
444
|
+
# geomorph scales to unit energy, so need to rescale ref_python.
|
|
445
|
+
ref_python = set_unit_energy(ref_python)
|
|
446
|
+
# Compare resulting consensus shapes.
|
|
447
|
+
error_percent = max_abs_error_relative_to_mean_radius(ref_R, ref_python)
|
|
448
|
+
# print("Comparison of consensus shape recovered by geomorph (R) and "
|
|
449
|
+
# "facepackage (Python).")
|
|
450
|
+
# print(f"\tMax abs error as percent of mean radius\t: {error_percent}")
|
|
451
|
+
assert error_percent < 1
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_generalized_procrustes_analysis_geomorph_2():
|
|
455
|
+
geo_data = get_geomorph_plethodon_data()
|
|
456
|
+
shapes = geo_data[0]
|
|
457
|
+
aligned_R = geo_data[2]
|
|
458
|
+
# Consensus shape & aligned shapes using this Python package.
|
|
459
|
+
ref_python, aligned_python, D = pt.generalized_procrustes_analysis(shapes)
|
|
460
|
+
# print("geomorph outputs unit scale shapes. do same with python output.")
|
|
461
|
+
aligned_python = set_unit_energy(aligned_python)
|
|
462
|
+
aligned_python = np.array(aligned_python)
|
|
463
|
+
# pdb.set_trace() # TEMPORARY FOR DEBUGGING
|
|
464
|
+
# Compare shapes aligned by the two methods.
|
|
465
|
+
error_percent = max_abs_error_relative_to_mean_radius(aligned_R,
|
|
466
|
+
aligned_python)
|
|
467
|
+
# print("Comparison of shapes aligned by geomorph (R) and by"
|
|
468
|
+
# "facepackage (Python).")
|
|
469
|
+
# print(f"\tMax abs error as percent of mean radius\t: {error_percent}")
|
|
470
|
+
assert error_percent < 1
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_generalized_procrustes_analysis_recover_donut_center():
|
|
474
|
+
"""
|
|
475
|
+
Recover a base shape (prototype) that does not exist in analyzed shapes.
|
|
476
|
+
"""
|
|
477
|
+
base_shape = np.array([-6, -8, 6, -8, 0, 16]).astype(float)
|
|
478
|
+
shapes = _make_shapes_around_base(base_shape, num_pairs=100)
|
|
479
|
+
reference_shape, new_shapes, D = pt.generalized_procrustes_analysis(shapes)
|
|
480
|
+
|
|
481
|
+
# New reference should be the same 'shape' as the base, or very close.
|
|
482
|
+
# But likely does not have the right scale and orientation.
|
|
483
|
+
aligned_reference = pt.procrustes_analysis(base_shape, reference_shape)
|
|
484
|
+
error_percent = max_abs_error_relative_to_mean_radius(base_shape,
|
|
485
|
+
aligned_reference)
|
|
486
|
+
# print("Recover base shape at center of sampled shapes. "
|
|
487
|
+
# "Exact shape variable.")
|
|
488
|
+
# print(f"\tMax abs error as percent of mean radius\t: {error_percent}")
|
|
489
|
+
assert error_percent < 1
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
#################################################################
|
|
493
|
+
# UNIT TESTS
|
|
494
|
+
#################################################################
|
|
495
|
+
|
|
496
|
+
# Basic functions.
|
|
497
|
+
test_translate_1()
|
|
498
|
+
test_translate_2()
|
|
499
|
+
test_rotate_1()
|
|
500
|
+
test_rotate_2()
|
|
501
|
+
|
|
502
|
+
# Alignment functions -
|
|
503
|
+
# Shapes are equivalent except for affine transform.
|
|
504
|
+
# Expect near-perfect alignments.
|
|
505
|
+
test_theta_in_get_rotation_scale()
|
|
506
|
+
test_scale_in_get_rotation_scale()
|
|
507
|
+
test_procrustes_analysis()
|
|
508
|
+
test_generalized_procrustes_analysis_1()
|
|
509
|
+
|
|
510
|
+
# *** all below involve more than 2 input shapes.
|
|
511
|
+
test_generalized_procrustes_analysis_distortion()
|
|
512
|
+
test_generalized_procrustes_analysis_equivalent_shapes()
|
|
513
|
+
|
|
514
|
+
# Alignment functions -
|
|
515
|
+
# Shapes cannot be perfectly aligned by affine transform.
|
|
516
|
+
# only 3 input shapes
|
|
517
|
+
# only match 'shape features'
|
|
518
|
+
test_generalized_procrustes_analysis_2()
|
|
519
|
+
test_generalized_procrustes_analysis_geomorph_1()
|
|
520
|
+
test_generalized_procrustes_analysis_geomorph_2()
|
|
521
|
+
test_generalized_procrustes_analysis_recover_donut_center()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
###############################################################################
|
|
525
|
+
###############################################################################
|
|
526
|
+
# Limits of simple affine.
|
|
527
|
+
# base_shape
|
|
528
|
+
# theta = np.pi / 2
|
|
529
|
+
# r_shape = pt.rotate(base_shape, theta)
|
|
530
|
+
# a_shape = pt.procrustes_analysis(base_shape, r_shape)
|
|
531
|
+
# plot_shapes([base_shape, a_shape])
|
|
532
|
+
# err = max_abs_error_relative_to_mean_radius(base_shape, a_shape)
|
|
533
|
+
#
|
|
534
|
+
# degrees = [-d for d in range(180)]
|
|
535
|
+
# radians = [d * np.pi/180 for d in degrees]
|
|
536
|
+
# E = []
|
|
537
|
+
# for theta in radians:
|
|
538
|
+
# r_shape = pt.rotate(base_shape, theta)
|
|
539
|
+
# a_shape = pt.procrustes_analysis(base_shape, r_shape)
|
|
540
|
+
# err = max_abs_error_relative_to_mean_radius(base_shape, a_shape)
|
|
541
|
+
# E.append(err)
|
|
542
|
+
# plt.plot(degrees, E)
|
|
543
|
+
#
|
|
544
|
+
# delta = np.diff(np.array(E), n=1)
|
|
545
|
+
# plt.plot(delta)
|
|
546
|
+
# delta[0:100].argmax()
|
|
547
|
+
# E[90]
|
|
548
|
+
# E[91]
|
|
549
|
+
# degrees[90:92]
|
|
550
|
+
#
|
|
551
|
+
# # Rotations above 90 degrees lead to jump in error.
|
|
552
|
+
# base_shape = np.random.standard_normal((2 * 1000, ))
|
|
553
|
+
# base_shape = pt.translate(base_shape)
|
|
554
|
+
# E = []
|
|
555
|
+
# for theta in radians:
|
|
556
|
+
# r_shape = pt.rotate(base_shape, theta)
|
|
557
|
+
# a_shape = pt.procrustes_analysis(base_shape, r_shape)
|
|
558
|
+
# err = max_abs_error_relative_to_mean_radius(base_shape, a_shape)
|
|
559
|
+
# E.append(err)
|
|
560
|
+
# plt.plot(degrees, E)
|
|
561
|
+
# E[90], E[91]
|
|
562
|
+
# degrees[90:92]
|
|
563
|
+
#
|
|
564
|
+
# # Avoid any rotation greater than pi/2 or less than -pi/2
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# END
|
|
568
|
+
##############################################################################
|
|
569
|
+
##############################################################################
|