test_slerp.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import math
  2. import warnings
  3. import numpy as np
  4. from numpy.testing import assert_allclose
  5. import pytest
  6. from scipy.spatial import geometric_slerp
  7. def _generate_spherical_points(ndim=3, n_pts=2):
  8. # generate uniform points on sphere
  9. # see: https://stackoverflow.com/a/23785326
  10. # tentatively extended to arbitrary dims
  11. # for 0-sphere it will always produce antipodes
  12. np.random.seed(123)
  13. points = np.random.normal(size=(n_pts, ndim))
  14. points /= np.linalg.norm(points, axis=1)[:, np.newaxis]
  15. return points[0], points[1]
  16. class TestGeometricSlerp:
  17. # Test various properties of the geometric slerp code
  18. @pytest.mark.parametrize("n_dims", [2, 3, 5, 7, 9])
  19. @pytest.mark.parametrize("n_pts", [0, 3, 17])
  20. def test_shape_property(self, n_dims, n_pts):
  21. # geometric_slerp output shape should match
  22. # input dimensionality & requested number
  23. # of interpolation points
  24. start, end = _generate_spherical_points(n_dims, 2)
  25. actual = geometric_slerp(start=start,
  26. end=end,
  27. t=np.linspace(0, 1, n_pts))
  28. assert actual.shape == (n_pts, n_dims)
  29. @pytest.mark.parametrize("n_dims", [2, 3, 5, 7, 9])
  30. @pytest.mark.parametrize("n_pts", [3, 17])
  31. def test_include_ends(self, n_dims, n_pts):
  32. # geometric_slerp should return a data structure
  33. # that includes the start and end coordinates
  34. # when t includes 0 and 1 ends
  35. # this is convenient for plotting surfaces represented
  36. # by interpolations for example
  37. # the generator doesn't work so well for the unit
  38. # sphere (it always produces antipodes), so use
  39. # custom values there
  40. start, end = _generate_spherical_points(n_dims, 2)
  41. actual = geometric_slerp(start=start,
  42. end=end,
  43. t=np.linspace(0, 1, n_pts))
  44. assert_allclose(actual[0], start)
  45. assert_allclose(actual[-1], end)
  46. @pytest.mark.parametrize("start, end", [
  47. # both arrays are not flat
  48. (np.zeros((1, 3)), np.ones((1, 3))),
  49. # only start array is not flat
  50. (np.zeros((1, 3)), np.ones(3)),
  51. # only end array is not flat
  52. (np.zeros(1), np.ones((3, 1))),
  53. ])
  54. def test_input_shape_flat(self, start, end):
  55. # geometric_slerp should handle input arrays that are
  56. # not flat appropriately
  57. with pytest.raises(ValueError, match='one-dimensional'):
  58. geometric_slerp(start=start,
  59. end=end,
  60. t=np.linspace(0, 1, 10))
  61. @pytest.mark.parametrize("start, end", [
  62. # 7-D and 3-D ends
  63. (np.zeros(7), np.ones(3)),
  64. # 2-D and 1-D ends
  65. (np.zeros(2), np.ones(1)),
  66. # empty, "3D" will also get caught this way
  67. (np.array([]), np.ones(3)),
  68. ])
  69. def test_input_dim_mismatch(self, start, end):
  70. # geometric_slerp must appropriately handle cases where
  71. # an interpolation is attempted across two different
  72. # dimensionalities
  73. with pytest.raises(ValueError, match='dimensions'):
  74. geometric_slerp(start=start,
  75. end=end,
  76. t=np.linspace(0, 1, 10))
  77. @pytest.mark.parametrize("start, end", [
  78. # both empty
  79. (np.array([]), np.array([])),
  80. ])
  81. def test_input_at_least1d(self, start, end):
  82. # empty inputs to geometric_slerp must
  83. # be handled appropriately when not detected
  84. # by mismatch
  85. with pytest.raises(ValueError, match='at least two-dim'):
  86. geometric_slerp(start=start,
  87. end=end,
  88. t=np.linspace(0, 1, 10))
  89. @pytest.mark.parametrize("start, end, expected", [
  90. # North and South Poles are definitely antipodes
  91. # but should be handled gracefully now
  92. (np.array([0, 0, 1.0]), np.array([0, 0, -1.0]), "warning"),
  93. # this case will issue a warning & be handled
  94. # gracefully as well;
  95. # North Pole was rotated very slightly
  96. # using r = R.from_euler('x', 0.035, degrees=True)
  97. # to achieve Euclidean distance offset from diameter by
  98. # 9.328908379124812e-08, within the default tol
  99. (np.array([0.00000000e+00,
  100. -6.10865200e-04,
  101. 9.99999813e-01]), np.array([0, 0, -1.0]), "warning"),
  102. # this case should succeed without warning because a
  103. # sufficiently large
  104. # rotation was applied to North Pole point to shift it
  105. # to a Euclidean distance of 2.3036691931821451e-07
  106. # from South Pole, which is larger than tol
  107. (np.array([0.00000000e+00,
  108. -9.59930941e-04,
  109. 9.99999539e-01]), np.array([0, 0, -1.0]), "success"),
  110. ])
  111. def test_handle_antipodes(self, start, end, expected):
  112. # antipodal points must be handled appropriately;
  113. # there are an infinite number of possible geodesic
  114. # interpolations between them in higher dims
  115. if expected == "warning":
  116. with pytest.warns(UserWarning, match='antipodes'):
  117. res = geometric_slerp(start=start,
  118. end=end,
  119. t=np.linspace(0, 1, 10))
  120. else:
  121. res = geometric_slerp(start=start,
  122. end=end,
  123. t=np.linspace(0, 1, 10))
  124. # antipodes or near-antipodes should still produce
  125. # slerp paths on the surface of the sphere (but they
  126. # may be ambiguous):
  127. assert_allclose(np.linalg.norm(res, axis=1), 1.0)
  128. @pytest.mark.parametrize("start, end, expected", [
  129. # 2-D with n_pts=4 (two new interpolation points)
  130. # this is an actual circle
  131. (np.array([1, 0]),
  132. np.array([0, 1]),
  133. np.array([[1, 0],
  134. [np.sqrt(3) / 2, 0.5], # 30 deg on unit circle
  135. [0.5, np.sqrt(3) / 2], # 60 deg on unit circle
  136. [0, 1]])),
  137. # likewise for 3-D (add z = 0 plane)
  138. # this is an ordinary sphere
  139. (np.array([1, 0, 0]),
  140. np.array([0, 1, 0]),
  141. np.array([[1, 0, 0],
  142. [np.sqrt(3) / 2, 0.5, 0],
  143. [0.5, np.sqrt(3) / 2, 0],
  144. [0, 1, 0]])),
  145. # for 5-D, pad more columns with constants
  146. # zeros are easiest--non-zero values on unit
  147. # circle are more difficult to reason about
  148. # at higher dims
  149. (np.array([1, 0, 0, 0, 0]),
  150. np.array([0, 1, 0, 0, 0]),
  151. np.array([[1, 0, 0, 0, 0],
  152. [np.sqrt(3) / 2, 0.5, 0, 0, 0],
  153. [0.5, np.sqrt(3) / 2, 0, 0, 0],
  154. [0, 1, 0, 0, 0]])),
  155. ])
  156. def test_straightforward_examples(self, start, end, expected):
  157. # some straightforward interpolation tests, sufficiently
  158. # simple to use the unit circle to deduce expected values;
  159. # for larger dimensions, pad with constants so that the
  160. # data is N-D but simpler to reason about
  161. actual = geometric_slerp(start=start,
  162. end=end,
  163. t=np.linspace(0, 1, 4))
  164. assert_allclose(actual, expected, atol=1e-16)
  165. @pytest.mark.parametrize("start, end", [
  166. (np.array([1]),
  167. np.array([0])),
  168. (np.array([0]),
  169. np.array([1])),
  170. (np.array([-17.7]),
  171. np.array([165.9])),
  172. ])
  173. def test_0_sphere_handling(self, start, end):
  174. # it does not make sense to interpolate the set of
  175. # two points that is the 0-sphere
  176. with pytest.raises(ValueError, match='at least two-dim'):
  177. _ = geometric_slerp(start=start,
  178. end=end,
  179. t=np.linspace(0, 1, 4))
  180. @pytest.mark.parametrize("tol", [
  181. # an integer currently raises
  182. 5,
  183. # string raises
  184. "7",
  185. # list and arrays also raise
  186. [5, 6, 7], np.array(9.0),
  187. ])
  188. def test_tol_type(self, tol):
  189. # geometric_slerp() should raise if tol is not
  190. # a suitable float type
  191. with pytest.raises(ValueError, match='must be a float'):
  192. _ = geometric_slerp(start=np.array([1, 0]),
  193. end=np.array([0, 1]),
  194. t=np.linspace(0, 1, 5),
  195. tol=tol)
  196. @pytest.mark.parametrize("tol", [
  197. -5e-6,
  198. -7e-10,
  199. ])
  200. def test_tol_sign(self, tol):
  201. # geometric_slerp() currently handles negative
  202. # tol values, as long as they are floats
  203. _ = geometric_slerp(start=np.array([1, 0]),
  204. end=np.array([0, 1]),
  205. t=np.linspace(0, 1, 5),
  206. tol=tol)
  207. @pytest.mark.parametrize("start, end", [
  208. # 1-sphere (circle) with one point at origin
  209. # and the other on the circle
  210. (np.array([1, 0]), np.array([0, 0])),
  211. # 2-sphere (normal sphere) with both points
  212. # just slightly off sphere by the same amount
  213. # in different directions
  214. (np.array([1 + 1e-6, 0, 0]),
  215. np.array([0, 1 - 1e-6, 0])),
  216. # same thing in 4-D
  217. (np.array([1 + 1e-6, 0, 0, 0]),
  218. np.array([0, 1 - 1e-6, 0, 0])),
  219. ])
  220. def test_unit_sphere_enforcement(self, start, end):
  221. # geometric_slerp() should raise on input that clearly
  222. # cannot be on an n-sphere of radius 1
  223. with pytest.raises(ValueError, match='unit n-sphere'):
  224. geometric_slerp(start=start,
  225. end=end,
  226. t=np.linspace(0, 1, 5))
  227. @pytest.mark.parametrize("start, end", [
  228. # 1-sphere 45 degree case
  229. (np.array([1, 0]),
  230. np.array([np.sqrt(2) / 2.,
  231. np.sqrt(2) / 2.])),
  232. # 2-sphere 135 degree case
  233. (np.array([1, 0]),
  234. np.array([-np.sqrt(2) / 2.,
  235. np.sqrt(2) / 2.])),
  236. ])
  237. @pytest.mark.parametrize("t_func", [
  238. np.linspace, np.logspace])
  239. def test_order_handling(self, start, end, t_func):
  240. # geometric_slerp() should handle scenarios with
  241. # ascending and descending t value arrays gracefully;
  242. # results should simply be reversed
  243. # for scrambled / unsorted parameters, the same values
  244. # should be returned, just in scrambled order
  245. num_t_vals = 20
  246. np.random.seed(789)
  247. forward_t_vals = t_func(0, 10, num_t_vals)
  248. # normalize to max of 1
  249. forward_t_vals /= forward_t_vals.max()
  250. reverse_t_vals = np.flipud(forward_t_vals)
  251. shuffled_indices = np.arange(num_t_vals)
  252. np.random.shuffle(shuffled_indices)
  253. scramble_t_vals = forward_t_vals.copy()[shuffled_indices]
  254. forward_results = geometric_slerp(start=start,
  255. end=end,
  256. t=forward_t_vals)
  257. reverse_results = geometric_slerp(start=start,
  258. end=end,
  259. t=reverse_t_vals)
  260. scrambled_results = geometric_slerp(start=start,
  261. end=end,
  262. t=scramble_t_vals)
  263. # check fidelity to input order
  264. assert_allclose(forward_results, np.flipud(reverse_results))
  265. assert_allclose(forward_results[shuffled_indices],
  266. scrambled_results)
  267. @pytest.mark.parametrize("t", [
  268. # string:
  269. "15, 5, 7",
  270. # complex numbers currently produce a warning
  271. # but not sure we need to worry about it too much:
  272. # [3 + 1j, 5 + 2j],
  273. ])
  274. def test_t_values_conversion(self, t):
  275. with pytest.raises(ValueError):
  276. _ = geometric_slerp(start=np.array([1]),
  277. end=np.array([0]),
  278. t=t)
  279. def test_accept_arraylike(self):
  280. # array-like support requested by reviewer
  281. # in gh-10380
  282. actual = geometric_slerp([1, 0], [0, 1], [0, 1/3, 0.5, 2/3, 1])
  283. # expected values are based on visual inspection
  284. # of the unit circle for the progressions along
  285. # the circumference provided in t
  286. expected = np.array([[1, 0],
  287. [np.sqrt(3) / 2, 0.5],
  288. [np.sqrt(2) / 2,
  289. np.sqrt(2) / 2],
  290. [0.5, np.sqrt(3) / 2],
  291. [0, 1]], dtype=np.float64)
  292. # Tyler's original Cython implementation of geometric_slerp
  293. # can pass at atol=0 here, but on balance we will accept
  294. # 1e-16 for an implementation that avoids Cython and
  295. # makes up accuracy ground elsewhere
  296. assert_allclose(actual, expected, atol=1e-16)
  297. def test_scalar_t(self):
  298. # when t is a scalar, return value is a single
  299. # interpolated point of the appropriate dimensionality
  300. # requested by reviewer in gh-10380
  301. actual = geometric_slerp([1, 0], [0, 1], 0.5)
  302. expected = np.array([np.sqrt(2) / 2,
  303. np.sqrt(2) / 2], dtype=np.float64)
  304. assert actual.shape == (2,)
  305. assert_allclose(actual, expected)
  306. @pytest.mark.parametrize('start', [
  307. np.array([1, 0, 0]),
  308. np.array([0, 1]),
  309. ])
  310. @pytest.mark.parametrize('t', [
  311. np.array(1),
  312. np.array([1]),
  313. np.array([[1]]),
  314. np.array([[[1]]]),
  315. np.array([]),
  316. np.linspace(0, 1, 5),
  317. ])
  318. def test_degenerate_input(self, start, t):
  319. if np.asarray(t).ndim > 1:
  320. with pytest.raises(ValueError):
  321. geometric_slerp(start=start, end=start, t=t)
  322. else:
  323. shape = (t.size,) + start.shape
  324. expected = np.full(shape, start)
  325. actual = geometric_slerp(start=start, end=start, t=t)
  326. assert_allclose(actual, expected)
  327. # Check that degenerate and non-degenerate
  328. # inputs yield the same size
  329. non_degenerate = geometric_slerp(start=start, end=start[::-1], t=t)
  330. assert actual.size == non_degenerate.size
  331. @pytest.mark.parametrize('k', np.logspace(-10, -1, 10))
  332. def test_numerical_stability_pi(self, k):
  333. # geometric_slerp should have excellent numerical
  334. # stability for angles approaching pi between
  335. # the start and end points
  336. angle = np.pi - k
  337. ts = np.linspace(0, 1, 100)
  338. P = np.array([1, 0, 0, 0])
  339. Q = np.array([np.cos(angle), np.sin(angle), 0, 0])
  340. # the test should only be enforced for cases where
  341. # geometric_slerp determines that the input is actually
  342. # on the unit sphere
  343. with warnings.catch_warnings():
  344. warnings.simplefilter("ignore", UserWarning)
  345. result = geometric_slerp(P, Q, ts, 1e-18)
  346. norms = np.linalg.norm(result, axis=1)
  347. error = np.max(np.abs(norms - 1))
  348. assert error < 4e-15
  349. @pytest.mark.parametrize('t', [
  350. [[0, 0.5]],
  351. [[[[[[[[[0, 0.5]]]]]]]]],
  352. ])
  353. def test_interpolation_param_ndim(self, t):
  354. # regression test for gh-14465
  355. arr1 = np.array([0, 1])
  356. arr2 = np.array([1, 0])
  357. with pytest.raises(ValueError):
  358. geometric_slerp(start=arr1,
  359. end=arr2,
  360. t=t)
  361. with pytest.raises(ValueError):
  362. geometric_slerp(start=arr1,
  363. end=arr1,
  364. t=t)
  365. @pytest.mark.parametrize("start, end, t, expected", [
  366. # verify intuitive cases for t > 1 or < 0 (extrapolation)
  367. # quarter way around the unit circle, doubling to
  368. # half way around (allowed antipode since non-ambiguous
  369. # geodesic):
  370. ([0, 1], [1, 0], 2, [0, -1]),
  371. # 1/8 way around the unit circle, quadrupling to half way
  372. # (another allowed non-ambiguous antipode)
  373. ([0, 1], [math.sqrt(2)/2, math.sqrt(2)/2], 4, [0, -1]),
  374. # 1/8 way around the unit circle, 6x to 3/4 way around (270 deg)
  375. ([0, 1], [math.sqrt(2)/2, math.sqrt(2)/2], 6, [-1, 0]),
  376. # 1/8 way around the unit circle, 7x to 315 deg
  377. ([0, 1], [math.sqrt(2)/2, math.sqrt(2)/2], 7,
  378. [-math.sqrt(2)/2, math.sqrt(2)/2]),
  379. # the above three extrapolations in reverse order should be returned
  380. # in that same order:
  381. ([0, 1],
  382. [math.sqrt(2)/2, math.sqrt(2)/2],
  383. [7, 6, 4],
  384. [[-math.sqrt(2)/2, math.sqrt(2)/2],
  385. [-1, 0],
  386. [0, -1]]),
  387. # same case in 3d (sphere), in the xy plane:
  388. ([0, 1, 0],
  389. [math.sqrt(2)/2, math.sqrt(2)/2, 0],
  390. [7, 6, 4],
  391. [[-math.sqrt(2)/2, math.sqrt(2)/2, 0],
  392. [-1, 0, 0],
  393. [0, -1, 0]]),
  394. # 1/8 way around the unit circle, moving 300 deg backwards
  395. # should end up at pi/6 (since there is no forward travel with
  396. # negative extrapolation)
  397. ([0, 1], [math.sqrt(2)/2, math.sqrt(2)/2], (-300/45), [math.sqrt(3)/2, 0.5]),
  398. ])
  399. def test_extrapolation_basic(self, start, end, t, expected):
  400. actual_path = geometric_slerp(start=start,
  401. end=end,
  402. t=t)
  403. assert_allclose(actual_path, expected, atol=2e-16)
  404. @pytest.mark.parametrize("start, end, t, expected", [
  405. # cases where start and end proper are antipodes
  406. # and t > 1 or < 0; these cases should issue a warning due to
  407. # geodesic ambiguity
  408. # North to South on unit sphere, then back to North again;
  409. # there are an infinite number of possible routes, but they all
  410. # return back to North
  411. ([0, 0, 1], [0, 0, -1], -2, [0, 0, 1]),
  412. # move "backwards" to South pole via an infinite number
  413. # of possible routes
  414. ([0, 0, 1], [0, 0, -1], -1, [0, 0, -1]),
  415. ])
  416. def test_extrapolation_antipodes(self, start, end, t, expected):
  417. with pytest.warns(UserWarning, match='antipodes'):
  418. actual_path = geometric_slerp(start=start, end=end, t=t)
  419. assert_allclose(actual_path, expected, atol=2.8e-16)