Repository: scikit-image/scikit-image

Issue:
`convex_hull_image` returns different results between 0.18.0 and 0.19.3
## Description

<!--
(Note: for guidance on how to use `scikit-image`, please post instead on https://forum.image.sc/tag/scikit-image)
-->

Hi guys, my team and I recently decided to migrate to `scikit-image==0.19.3` due to a bigger migration to Python 3.10. Our algorithms rely heavily on `scikit-image` (0.14.2).

During the migration process, we encountered a difference in the `convex_hull_image` function. Seems like the underlying calculation was changed. I found this [issue](https://github.com/scikit-image/scikit-image/issues/3892) ([relevant PR](https://github.com/scikit-image/scikit-image/pull/5029)), and I saw that in the newer versions, the condition that checks whether a point is in a polygon was changed from this:

<b> Pre 0.18.1 </b>
```
min <= point < max
```

<b> Post 0.18.1 </b>
```
min <= point <= max
```

This is a bit problematic for us, because it changes the results of our algorithms pretty dramatically. I tried to look at the code a bit and I saw that `point_in_polygon` now returns 4 possible values: 
```
0: outside, 1: inside, 2: vertex; 3: edge
``` 

However, the function that `convex_hull_image` uses casts the array of numbers into a boolean mask. So for an array like this:
```
[0, 1, 2, 3]
```
It'd return this:
```
[False, True, True, True]
```

This implicitly includes edge/vertex points inside the polygon. This is a problem because our algorithms were not trained with this kind of shapes.

Is there a way to change this behavior outside of `scikit-image`? I couldn't find any flag that controls this behavior. The most feasible option that we thought about is to fork `scikit-image` and modify the `convex_hull_image` -> `grid_points_in_poly` -> `_grid_points_in_poly` to not cast the labels output to boolean and instead control this behavior with a flag. 

However, we prefer not to fork this project and change its internals.
Any suggestions would be highly appreciated!

## Way to reproduce
Run `convex_hull_image` in 0.14.2 and in 0.19.3 and witness the results. The mask in 0.19.3 will be slightly larger, due to the inclusion of edge/vertex points.


## Version information
```python
# Paste the output of the following python commands
from __future__ import print_function
import sys; print(sys.version)
import platform; print(platform.platform())
import skimage; print(f'scikit-image version: {skimage.__version__}')
import numpy; print(f'numpy version: {numpy.__version__}')
```

#### Output
```python
3.10.5 (main, Aug 14 2022, 07:33:04) [GCC 9.4.0]
Linux-5.15.0-1015-aws-x86_64-with-glibc2.31
scikit-image version: 0.19.3
numpy version: 1.23.1
```



Discussion:
Thanks for reporting this @dudulasry 

> The most feasible option that we thought about is to fork scikit-image and modify the convex_hull_image -> grid_points_in_poly -> _grid_points_in_poly to not cast the labels output to boolean and instead control this behavior with a flag.

Having a flag to offer finer control of the behavior seems like it could be a reasonable enhancement here. I am not too familiar with this issue, though. Perhaps @rfezzani can offer some perspective as it seems a [more general user choice was mentioned previously as an option](https://github.com/scikit-image/scikit-image/issues/3892#issuecomment-494296268).



Resolving patch:
diff --git a/skimage/measure/__init__.py b/skimage/measure/__init__.py
--- a/skimage/measure/__init__.py
+++ b/skimage/measure/__init__.py
@@ -3,7 +3,7 @@
 from ._regionprops import (regionprops, perimeter,
                            perimeter_crofton, euler_number, regionprops_table)
 from ._polygon import approximate_polygon, subdivide_polygon
-from .pnpoly import points_in_poly, grid_points_in_poly
+from .pnpoly import (points_in_poly, grid_points_in_poly)
 from ._moments import (moments, moments_central, moments_coords,
                        moments_coords_central, moments_normalized, centroid,
                        moments_hu, inertia_tensor, inertia_tensor_eigvals)
diff --git a/skimage/measure/pnpoly.py b/skimage/measure/pnpoly.py
--- a/skimage/measure/pnpoly.py
+++ b/skimage/measure/pnpoly.py
@@ -1,12 +1,15 @@
 from ._pnpoly import _grid_points_in_poly, _points_in_poly
 
 
-def grid_points_in_poly(shape, verts):
+def grid_points_in_poly(shape, verts, binarize=True):
     """Test whether points on a specified grid are inside a polygon.
 
     For each ``(r, c)`` coordinate on a grid, i.e. ``(0, 0)``, ``(0, 1)`` etc.,
     test whether that point lies inside a polygon.
 
+    You can control the output type with the `binarize` flag. Please refer to its
+    documentation for further details.
+
     Parameters
     ----------
     shape : tuple (M, N)
@@ -15,6 +18,10 @@ def grid_points_in_poly(shape, verts):
         Specify the V vertices of the polygon, sorted either clockwise
         or anti-clockwise. The first point may (but does not need to be)
         duplicated.
+    binarize: bool
+        If `True`, the output of the function is a boolean mask.
+        Otherwise, it is a labeled array. The labels are:
+        O - outside, 1 - inside, 2 - vertex, 3 - edge.
 
     See Also
     --------
@@ -22,11 +29,18 @@ def grid_points_in_poly(shape, verts):
 
     Returns
     -------
-    mask : (M, N) ndarray of bool
-        True where the grid falls inside the polygon.
+    mask : (M, N) ndarray
+        If `binarize` is True, the output is a boolean mask. True means the
+        corresponding pixel falls inside the polygon.
+        If `binarize` is False, the output is a labeled array, with pixels
+        having a label between 0 and 3. The meaning of the values is:
+        O - outside, 1 - inside, 2 - vertex, 3 - edge.
 
     """
-    return _grid_points_in_poly(shape, verts)
+    output = _grid_points_in_poly(shape, verts)
+    if binarize:
+        output = output.astype(bool)
+    return output
 
 
 def points_in_poly(points, verts):
diff --git a/skimage/morphology/convex_hull.py b/skimage/morphology/convex_hull.py
--- a/skimage/morphology/convex_hull.py
+++ b/skimage/morphology/convex_hull.py
@@ -77,7 +77,8 @@ def _check_coords_in_hull(gridcoords, hull_equations, tolerance):
     return coords_in_hull
 
 
-def convex_hull_image(image, offset_coordinates=True, tolerance=1e-10):
+def convex_hull_image(image, offset_coordinates=True, tolerance=1e-10,
+                      include_borders=True):
     """Compute the convex hull image of a binary image.
 
     The convex hull is the set of pixels included in the smallest convex
@@ -95,6 +96,8 @@ def convex_hull_image(image, offset_coordinates=True, tolerance=1e-10):
         Tolerance when determining whether a point is inside the hull. Due
         to numerical floating point errors, a tolerance of 0 can result in
         some points erroneously being classified as being outside the hull.
+    include_borders: bool, optional
+        If ``False``, vertices/edges are excluded from the final hull mask.
 
     Returns
     -------
@@ -152,7 +155,10 @@ def convex_hull_image(image, offset_coordinates=True, tolerance=1e-10):
 
     # If 2D, use fast Cython function to locate convex hull pixels
     if ndim == 2:
-        mask = grid_points_in_poly(image.shape, vertices)
+        labels = grid_points_in_poly(image.shape, vertices, binarize=False)
+        # If include_borders is True, we include vertices (2) and edge
+        # points (3) in the mask, otherwise only the inside of the hull (1)
+        mask = labels >= 1 if include_borders else labels == 1
     else:
         gridcoords = np.reshape(np.mgrid[tuple(map(slice, image.shape))],
                                 (ndim, -1))


Test patch:
diff --git a/skimage/measure/tests/test_pnpoly.py b/skimage/measure/tests/test_pnpoly.py
--- a/skimage/measure/tests/test_pnpoly.py
+++ b/skimage/measure/tests/test_pnpoly.py
@@ -33,3 +33,17 @@ def test_grid_points_in_poly():
     expected = np.tril(np.ones((5, 5), dtype=bool))
 
     assert_array_equal(grid_points_in_poly((5, 5), v), expected)
+
+
+def test_grid_points_in_poly_binarize():
+    v = np.array([[0, 0],
+                  [5, 0],
+                  [5, 5]])
+
+    expected = np.array([[2, 0, 0, 0, 0],
+                        [3, 3, 0, 0, 0],
+                        [3, 1, 3, 0, 0],
+                        [3, 1, 1, 3, 0],
+                        [3, 1, 1, 1, 3]])
+
+    assert_array_equal(grid_points_in_poly((5, 5), v, binarize=False), expected)
diff --git a/skimage/morphology/tests/test_convex_hull.py b/skimage/morphology/tests/test_convex_hull.py
--- a/skimage/morphology/tests/test_convex_hull.py
+++ b/skimage/morphology/tests/test_convex_hull.py
@@ -63,6 +63,19 @@ def test_pathological_qhull_example():
     assert_array_equal(convex_hull_image(image), expected)
 
 
+def test_pathological_qhull_labels():
+    image = np.array([[0, 0, 0, 0, 1, 0, 0],
+                      [0, 0, 1, 1, 1, 1, 1],
+                      [1, 1, 1, 0, 0, 0, 0]], dtype=bool)
+
+    expected = np.array([[0, 0, 0, 0, 1, 0, 0],
+                         [0, 0, 1, 1, 1, 1, 1],
+                         [1, 1, 1, 1, 0, 0, 0]], dtype=bool)
+
+    actual = convex_hull_image(image, include_borders=False)
+    assert_array_equal(actual, expected)
+
+
 def test_possible_hull():
     image = np.array(
         [[0, 0, 0, 0, 0, 0, 0, 0, 0],