Repository: panosz/alpha_shapes

Issue:
Bug in alpha optimization
Hey Panosz, 

first of all, thank you for  sharing your code. This is really helpful.
However, I found a small bug in the optimization of the parameter alpha.
Your current code for the function "optimize":

def optimize(self):
        # At least N//3 triangles are needed to connect N points.
        simplices = self._sorted_simplices()
        n_start = len(self)//3
        n_finish = len(self)+1
        uncovered_vertices = self._uncovered_vertices(simplices[:n_start])
        for n in range(n_start, n_finish):
            if not uncovered_vertices:
                alpha_opt = 1/np.sqrt(self._sorted_circumradii_sw()[n])   ########### HERE OCCURS THE PROBLEM #############
                shape = self._shape_from_simplices(simplices[:n])
                return alpha_opt, shape
            for vertices in simplices[n]:
                uncovered_vertices.discard(vertices)
        raise OptimizationFailure()


In alpha_shapes.alpha_shapes.py in line 105, it says:
alpha_opt = 1/np.sqrt(self._sorted_circumradii_sw()[n])

When n takes on the last value of the for-loop, n takes on the value of the number of simplices.
Thus, n is greater by 1 than the last index from the list "simplices".
Thus, we get the error n is out of range.
For example, we have 7 simplices, then n=7. However, the last index of the list "simplices" is 6.

You can produce this error by optimizing alpha for the following list of coordinates:
[(363820.32, 5771887.69),
 (363837.36, 5771916.33),
 (363870.03, 5771951.57),
 (363859.3, 5771943.9),
 (363829.7, 5771861.92),
 (363821.03, 5771850.18),
 (363844.05, 5771928.69),
 (363828.75, 5771906.28)]


Furthermore, you correctly stated that we need at least N//3 triangles to connect N Points.
However, when calculating the minimum number triangles, you do not take the number of points but the number of triangles as N:

n_start = len(self)//3

This does not produce an error, but might lead to a larger number of iterations. Thought I might mention it.


Here is my suggestion for fixing the two issues:

def optimize(self):
        simplices = self._sorted_simplices() 
        # Fix for the minimum number of triangle issue
        n_points = self.x.size # number of x_coordinates
        n_start = n_points//3 
        n_finish = len(self)+1 
        uncovered_vertices = self._uncovered_vertices(simplices[:n_start])  
        for n in range(n_start, n_finish):
            if not uncovered_vertices:
                # Fix for the out-of-range-issue
                if n == len(self): 
                    alpha_opt = 1/np.sqrt(self._sorted_circumradii_sw()[n-1]) 
                else:
                     alpha_opt = 1/np.sqrt(self._sorted_circumradii_sw()[n])
                shape = self._shape_from_simplices(simplices[:n]) 
                return alpha_opt, shape
            for vertices in simplices[n]:
                uncovered_vertices.discard(vertices)
        raise OptimizationFailure()

Best regards

Comments:

Patch:
diff --git a/alpha_shapes/alpha_shapes.py b/alpha_shapes/alpha_shapes.py
index e720a50..fe7b952 100644
--- a/alpha_shapes/alpha_shapes.py
+++ b/alpha_shapes/alpha_shapes.py
@@ -2,7 +2,7 @@
 Utility module for the calculation of alpha shapes
 """
 
-from functools import wraps
+import warnings
 
 import numpy as np
 from matplotlib.tri import Triangulation
@@ -23,6 +23,10 @@ class OptimizationFailure(AlphaException):
     pass
 
 
+class OptimizationWarnging(UserWarning):
+    pass
+
+
 class Delaunay(Triangulation):
     """
     Visitor sublclass of matplotlib.tri.Triangulation.
@@ -121,24 +125,39 @@ class Alpha_Shaper(Delaunay):
         n_points = self.x.size
         return set(range(n_points)) - set(np.ravel(simplices))
 
-    def optimize(self):
+    def _get_minimum_fully_covering_index_of_simplices(self):
+        """
+        Return the minimum index of simplices needed to cover all vertices.
+        The set of all simplices up to this index is fully covering.
+        """
         # At least N//3 triangles are needed to connect N points.
         simplices = self._sorted_simplices()
         n_start = len(self) // 3
         n_finish = len(self)
         uncovered_vertices = self._uncovered_vertices(simplices[:n_start])
+        if not uncovered_vertices:
+            return n_start
+
         for n in range(n_start, n_finish):
+            simplex = simplices[n]
+            uncovered_vertices -= set(simplex)
+
             if not uncovered_vertices:
-                alpha_opt = 1 / np.sqrt(self._sorted_circumradii_sw()[n])
-                shape = self._shape_from_simplices(simplices[:n])
-                self.set_mask_at_alpha(alpha_opt)
-                return alpha_opt, shape
+                return n
 
-            simplex = simplices[n]
-            for vertices in simplex:
-                uncovered_vertices.discard(vertices)
+        if uncovered_vertices:
+            raise OptimizationFailure("Maybe there are duplicate points?")
 
-        raise OptimizationFailure()
+    def optimize(self):
+        # At least N//3 triangles are needed to connect N points.
+        n_min = self._get_minimum_fully_covering_index_of_simplices()
+        print(n_min)
+        print(f"{len(self)=}")
+        alpha_opt = 1 / np.sqrt(self._sorted_circumradii_sw()[n_min]) - 1e-10
+        simplices = self._sorted_simplices()
+        shape = self._shape_from_simplices(simplices[: n_min + 1])
+        self.set_mask_at_alpha(alpha_opt)
+        return alpha_opt, shape
 
     def set_mask_at_alpha(self, alpha: float):
         """
diff --git a/examples/simple.py b/examples/simple.py
index 767f03e..c4459d6 100644
--- a/examples/simple.py
+++ b/examples/simple.py
@@ -56,7 +56,7 @@ ax0.scatter(*zip(*points))
 ax0.set_title('data')
 ax1.scatter(*zip(*points))
 plot_alpha_shape(ax1, alpha_shape)
-ax1.set_title(f"$\\alpha={alpha_opt:.3}$")
+ax1.set_title(f"$\\alpha_{{\\mathrm{{opt}}}}={alpha_opt:.3}$")
 
 for ax in (ax0, ax1):
     ax.set_aspect('equal')


Test patch:
diff --git a/tests/test_Alpha_Shaper.py b/tests/test_Alpha_Shaper.py
new file mode 100644
index 0000000..7824e64
--- /dev/null
+++ b/tests/test_Alpha_Shaper.py
@@ -0,0 +1,30 @@
+"""
+tests for Alpha_Shaper.py
+"""
+import numpy as np
+import pytest
+
+from alpha_shapes import Alpha_Shaper
+
+
+def test_optimization_with_strongly_shaped_points():
+    """
+    Test the optimization with strongly shaped points
+    issue #1
+    """
+    points = [
+        (363820.32, 5771887.69),
+        (363837.36, 5771916.33),
+        (363870.03, 5771951.57),
+        (363859.3, 5771943.9),
+        (363829.7, 5771861.92),
+        (363821.03, 5771850.18),
+        (363844.05, 5771928.69),
+        (363828.75, 5771906.28),
+    ]
+    shaper = Alpha_Shaper(points)
+    alpha_opt, alpha_shape = shaper.optimize()
+
+    # check that no simplex is masked
+    not_masked = np.logical_not(shaper.mask)
+    assert np.all(not_masked)