test_spherical_voronoi.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import numpy as np
  2. import itertools
  3. from numpy.testing import (assert_equal,
  4. assert_almost_equal,
  5. assert_array_equal,
  6. assert_array_almost_equal)
  7. import pytest
  8. from pytest import raises as assert_raises
  9. from scipy.spatial import SphericalVoronoi, distance
  10. from scipy.optimize import linear_sum_assignment
  11. from scipy.constants import golden as phi
  12. from scipy.special import gamma
  13. TOL = 1E-10
  14. def _generate_tetrahedron():
  15. return np.array([[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]])
  16. def _generate_cube():
  17. return np.array(list(itertools.product([-1, 1.], repeat=3)))
  18. def _generate_octahedron():
  19. return np.array([[-1, 0, 0], [+1, 0, 0], [0, -1, 0],
  20. [0, +1, 0], [0, 0, -1], [0, 0, +1]])
  21. def _generate_dodecahedron():
  22. x1 = _generate_cube()
  23. x2 = np.array([[0, -phi, -1 / phi],
  24. [0, -phi, +1 / phi],
  25. [0, +phi, -1 / phi],
  26. [0, +phi, +1 / phi]])
  27. x3 = np.array([[-1 / phi, 0, -phi],
  28. [+1 / phi, 0, -phi],
  29. [-1 / phi, 0, +phi],
  30. [+1 / phi, 0, +phi]])
  31. x4 = np.array([[-phi, -1 / phi, 0],
  32. [-phi, +1 / phi, 0],
  33. [+phi, -1 / phi, 0],
  34. [+phi, +1 / phi, 0]])
  35. return np.concatenate((x1, x2, x3, x4))
  36. def _generate_icosahedron():
  37. x = np.array([[0, -1, -phi],
  38. [0, -1, +phi],
  39. [0, +1, -phi],
  40. [0, +1, +phi]])
  41. return np.concatenate([np.roll(x, i, axis=1) for i in range(3)])
  42. def _generate_polytope(name):
  43. polygons = ["triangle", "square", "pentagon", "hexagon", "heptagon",
  44. "octagon", "nonagon", "decagon", "undecagon", "dodecagon"]
  45. polyhedra = ["tetrahedron", "cube", "octahedron", "dodecahedron",
  46. "icosahedron"]
  47. if name not in polygons and name not in polyhedra:
  48. raise ValueError("unrecognized polytope")
  49. if name in polygons:
  50. n = polygons.index(name) + 3
  51. thetas = np.linspace(0, 2 * np.pi, n, endpoint=False)
  52. p = np.vstack([np.cos(thetas), np.sin(thetas)]).T
  53. elif name == "tetrahedron":
  54. p = _generate_tetrahedron()
  55. elif name == "cube":
  56. p = _generate_cube()
  57. elif name == "octahedron":
  58. p = _generate_octahedron()
  59. elif name == "dodecahedron":
  60. p = _generate_dodecahedron()
  61. elif name == "icosahedron":
  62. p = _generate_icosahedron()
  63. return p / np.linalg.norm(p, axis=1, keepdims=True)
  64. def _hypersphere_area(dim, radius):
  65. # https://en.wikipedia.org/wiki/N-sphere#Closed_forms
  66. return 2 * np.pi**(dim / 2) / gamma(dim / 2) * radius**(dim - 1)
  67. def _sample_sphere(n, dim, seed=None):
  68. # Sample points uniformly at random from the hypersphere
  69. rng = np.random.RandomState(seed=seed)
  70. points = rng.randn(n, dim)
  71. points /= np.linalg.norm(points, axis=1, keepdims=True)
  72. return points
  73. class TestSphericalVoronoi:
  74. def setup_method(self):
  75. self.points = np.array([
  76. [-0.78928481, -0.16341094, 0.59188373],
  77. [-0.66839141, 0.73309634, 0.12578818],
  78. [0.32535778, -0.92476944, -0.19734181],
  79. [-0.90177102, -0.03785291, -0.43055335],
  80. [0.71781344, 0.68428936, 0.12842096],
  81. [-0.96064876, 0.23492353, -0.14820556],
  82. [0.73181537, -0.22025898, -0.6449281],
  83. [0.79979205, 0.54555747, 0.25039913]]
  84. )
  85. def test_constructor(self):
  86. center = np.array([1, 2, 3])
  87. radius = 2
  88. s1 = SphericalVoronoi(self.points)
  89. # user input checks in SphericalVoronoi now require
  90. # the radius / center to match the generators so adjust
  91. # accordingly here
  92. s2 = SphericalVoronoi(self.points * radius, radius)
  93. s3 = SphericalVoronoi(self.points + center, center=center)
  94. s4 = SphericalVoronoi(self.points * radius + center, radius, center)
  95. assert_array_equal(s1.center, np.array([0, 0, 0]))
  96. assert_equal(s1.radius, 1)
  97. assert_array_equal(s2.center, np.array([0, 0, 0]))
  98. assert_equal(s2.radius, 2)
  99. assert_array_equal(s3.center, center)
  100. assert_equal(s3.radius, 1)
  101. assert_array_equal(s4.center, center)
  102. assert_equal(s4.radius, radius)
  103. # Test a non-sequence/-ndarray based array-like
  104. s5 = SphericalVoronoi(memoryview(self.points)) # type: ignore[arg-type]
  105. assert_array_equal(s5.center, np.array([0, 0, 0]))
  106. assert_equal(s5.radius, 1)
  107. def test_vertices_regions_translation_invariance(self):
  108. sv_origin = SphericalVoronoi(self.points)
  109. center = np.array([1, 1, 1])
  110. sv_translated = SphericalVoronoi(self.points + center, center=center)
  111. assert_equal(sv_origin.regions, sv_translated.regions)
  112. assert_array_almost_equal(sv_origin.vertices + center,
  113. sv_translated.vertices)
  114. def test_vertices_regions_scaling_invariance(self):
  115. sv_unit = SphericalVoronoi(self.points)
  116. sv_scaled = SphericalVoronoi(self.points * 2, 2)
  117. assert_equal(sv_unit.regions, sv_scaled.regions)
  118. assert_array_almost_equal(sv_unit.vertices * 2,
  119. sv_scaled.vertices)
  120. def test_old_radius_api_error(self):
  121. with pytest.raises(ValueError, match='`radius` is `None`. *'):
  122. SphericalVoronoi(self.points, radius=None)
  123. def test_sort_vertices_of_regions(self):
  124. sv = SphericalVoronoi(self.points)
  125. unsorted_regions = sv.regions
  126. sv.sort_vertices_of_regions()
  127. assert_equal(sorted(sv.regions), sorted(unsorted_regions))
  128. def test_sort_vertices_of_regions_flattened(self):
  129. expected = sorted([[0, 6, 5, 2, 3], [2, 3, 10, 11, 8, 7], [0, 6, 4, 1],
  130. [4, 8, 7, 5, 6], [9, 11, 10], [2, 7, 5],
  131. [1, 4, 8, 11, 9], [0, 3, 10, 9, 1]])
  132. expected = list(itertools.chain(*sorted(expected))) # type: ignore
  133. sv = SphericalVoronoi(self.points)
  134. sv.sort_vertices_of_regions()
  135. actual = list(itertools.chain(*sorted(sv.regions)))
  136. assert_array_equal(actual, expected)
  137. def test_sort_vertices_of_regions_dimensionality(self):
  138. points = np.array([[1, 0, 0, 0],
  139. [0, 1, 0, 0],
  140. [0, 0, 1, 0],
  141. [0, 0, 0, 1],
  142. [0.5, 0.5, 0.5, 0.5]])
  143. with pytest.raises(TypeError, match="three-dimensional"):
  144. sv = SphericalVoronoi(points)
  145. sv.sort_vertices_of_regions()
  146. def test_num_vertices(self):
  147. # for any n >= 3, a spherical Voronoi diagram has 2n - 4
  148. # vertices; this is a direct consequence of Euler's formula
  149. # as explained by Dinis and Mamede (2010) Proceedings of the
  150. # 2010 International Symposium on Voronoi Diagrams in Science
  151. # and Engineering
  152. sv = SphericalVoronoi(self.points)
  153. expected = self.points.shape[0] * 2 - 4
  154. actual = sv.vertices.shape[0]
  155. assert_equal(actual, expected)
  156. def test_voronoi_circles(self):
  157. sv = SphericalVoronoi(self.points)
  158. for vertex in sv.vertices:
  159. distances = distance.cdist(sv.points, np.array([vertex]))
  160. closest = np.array(sorted(distances)[0:3])
  161. assert_almost_equal(closest[0], closest[1], 7, str(vertex))
  162. assert_almost_equal(closest[0], closest[2], 7, str(vertex))
  163. def test_duplicate_point_handling(self):
  164. # an exception should be raised for degenerate generators
  165. # related to Issue# 7046
  166. self.degenerate = np.concatenate((self.points, self.points))
  167. with assert_raises(ValueError):
  168. SphericalVoronoi(self.degenerate)
  169. def test_incorrect_radius_handling(self):
  170. # an exception should be raised if the radius provided
  171. # cannot possibly match the input generators
  172. with assert_raises(ValueError):
  173. SphericalVoronoi(self.points, radius=0.98)
  174. def test_incorrect_center_handling(self):
  175. # an exception should be raised if the center provided
  176. # cannot possibly match the input generators
  177. with assert_raises(ValueError):
  178. SphericalVoronoi(self.points, center=[0.1, 0, 0])
  179. @pytest.mark.parametrize("dim", range(2, 6))
  180. @pytest.mark.parametrize("shift", [False, True])
  181. def test_single_hemisphere_handling(self, dim, shift):
  182. n = 10
  183. points = _sample_sphere(n, dim, seed=0)
  184. points[:, 0] = np.abs(points[:, 0])
  185. center = (np.arange(dim) + 1) * shift
  186. sv = SphericalVoronoi(points + center, center=center)
  187. dots = np.einsum('ij,ij->i', sv.vertices - center,
  188. sv.points[sv._simplices[:, 0]] - center)
  189. circumradii = np.arccos(np.clip(dots, -1, 1))
  190. assert np.max(circumradii) > np.pi / 2
  191. @pytest.mark.parametrize("n", [1, 2, 10])
  192. @pytest.mark.parametrize("dim", range(2, 6))
  193. @pytest.mark.parametrize("shift", [False, True])
  194. def test_rank_deficient(self, n, dim, shift):
  195. center = (np.arange(dim) + 1) * shift
  196. points = _sample_sphere(n, dim - 1, seed=0)
  197. points = np.hstack([points, np.zeros((n, 1))])
  198. with pytest.raises(ValueError, match="Rank of input points"):
  199. SphericalVoronoi(points + center, center=center)
  200. @pytest.mark.parametrize("dim", range(2, 6))
  201. def test_higher_dimensions(self, dim):
  202. n = 100
  203. points = _sample_sphere(n, dim, seed=0)
  204. sv = SphericalVoronoi(points)
  205. assert sv.vertices.shape[1] == dim
  206. assert len(sv.regions) == n
  207. # verify Euler characteristic
  208. cell_counts = []
  209. simplices = np.sort(sv._simplices)
  210. for i in range(1, dim + 1):
  211. cells = []
  212. for indices in itertools.combinations(range(dim), i):
  213. cells.append(simplices[:, list(indices)])
  214. cells = np.unique(np.concatenate(cells), axis=0)
  215. cell_counts.append(len(cells))
  216. expected_euler = 1 + (-1)**(dim-1)
  217. actual_euler = sum([(-1)**i * e for i, e in enumerate(cell_counts)])
  218. assert expected_euler == actual_euler
  219. @pytest.mark.parametrize("dim", range(2, 6))
  220. def test_cross_polytope_regions(self, dim):
  221. # The hypercube is the dual of the cross-polytope, so the voronoi
  222. # vertices of the cross-polytope lie on the points of the hypercube.
  223. # generate points of the cross-polytope
  224. points = np.concatenate((-np.eye(dim), np.eye(dim)))
  225. sv = SphericalVoronoi(points)
  226. assert all([len(e) == 2**(dim - 1) for e in sv.regions])
  227. # generate points of the hypercube
  228. expected = np.vstack(list(itertools.product([-1, 1], repeat=dim)))
  229. expected = expected.astype(np.float64) / np.sqrt(dim)
  230. # test that Voronoi vertices are correctly placed
  231. dist = distance.cdist(sv.vertices, expected)
  232. res = linear_sum_assignment(dist)
  233. assert dist[res].sum() < TOL
  234. @pytest.mark.parametrize("dim", range(2, 6))
  235. def test_hypercube_regions(self, dim):
  236. # The cross-polytope is the dual of the hypercube, so the voronoi
  237. # vertices of the hypercube lie on the points of the cross-polytope.
  238. # generate points of the hypercube
  239. points = np.vstack(list(itertools.product([-1, 1], repeat=dim)))
  240. points = points.astype(np.float64) / np.sqrt(dim)
  241. sv = SphericalVoronoi(points)
  242. # generate points of the cross-polytope
  243. expected = np.concatenate((-np.eye(dim), np.eye(dim)))
  244. # test that Voronoi vertices are correctly placed
  245. dist = distance.cdist(sv.vertices, expected)
  246. res = linear_sum_assignment(dist)
  247. assert dist[res].sum() < TOL
  248. @pytest.mark.parametrize("n", [10, 500])
  249. @pytest.mark.parametrize("dim", [2, 3])
  250. @pytest.mark.parametrize("radius", [0.5, 1, 2])
  251. @pytest.mark.parametrize("shift", [False, True])
  252. @pytest.mark.parametrize("single_hemisphere", [False, True])
  253. def test_area_reconstitution(self, n, dim, radius, shift,
  254. single_hemisphere):
  255. points = _sample_sphere(n, dim, seed=0)
  256. # move all points to one side of the sphere for single-hemisphere test
  257. if single_hemisphere:
  258. points[:, 0] = np.abs(points[:, 0])
  259. center = (np.arange(dim) + 1) * shift
  260. points = radius * points + center
  261. sv = SphericalVoronoi(points, radius=radius, center=center)
  262. areas = sv.calculate_areas()
  263. assert_almost_equal(areas.sum(), _hypersphere_area(dim, radius))
  264. @pytest.mark.parametrize("poly", ["triangle", "dodecagon",
  265. "tetrahedron", "cube", "octahedron",
  266. "dodecahedron", "icosahedron"])
  267. def test_equal_area_reconstitution(self, poly):
  268. points = _generate_polytope(poly)
  269. n, dim = points.shape
  270. sv = SphericalVoronoi(points)
  271. areas = sv.calculate_areas()
  272. assert_almost_equal(areas, _hypersphere_area(dim, 1) / n)
  273. def test_area_unsupported_dimension(self):
  274. dim = 4
  275. points = np.concatenate((-np.eye(dim), np.eye(dim)))
  276. sv = SphericalVoronoi(points)
  277. with pytest.raises(TypeError, match="Only supported"):
  278. sv.calculate_areas()
  279. @pytest.mark.parametrize("radius", [1, 1.])
  280. @pytest.mark.parametrize("center", [None, (1, 2, 3), (1., 2., 3.)])
  281. def test_attribute_types(self, radius, center):
  282. points = radius * self.points
  283. if center is not None:
  284. points += center
  285. sv = SphericalVoronoi(points, radius=radius, center=center)
  286. assert sv.points.dtype is np.dtype(np.float64)
  287. assert sv.center.dtype is np.dtype(np.float64)
  288. assert isinstance(sv.radius, float)
  289. def test_region_types(self):
  290. # Tests that region integer type does not change
  291. # See Issue #13412
  292. sv = SphericalVoronoi(self.points)
  293. dtype = type(sv.regions[0][0])
  294. sv.sort_vertices_of_regions()
  295. assert type(sv.regions[0][0]) == dtype
  296. sv.sort_vertices_of_regions()
  297. assert type(sv.regions[0][0]) == dtype