Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fast 2d convex hull #5155

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

Fast 2d convex hull #5155

wants to merge 2 commits into from

Conversation

lmmx
Copy link

@lmmx lmmx commented Dec 23, 2020

Description

Bug fix for the patch submitted by @ehusby in #2928 3 years ago (which this PR supersedes), following advice from @jni to send in a fresh PR (as I don't have merge permissions so can't append to that PR as I previously tried to)

  • The original PR computed a faster convex hull (see proposal in that thread for details).
  • There was discussion about controlling whether to use the fast version with a flag, but this was not followed up on, which I now implement in this PR (I cannot find a way to include his commits unfortunately 😞)

My contribution is to fix a bug in the application of offsets to the computed convex hull (specifically an IndexError which arose when a half-pixel was rounded to a whole pixel and this whole pixel was one pixel outside of the perimeter of the image, and thus outside of the mask made from the image, which it tried to index into resulting in an error).

Further to this, I have added in conditional checks so that this new routine is only used in the 2D case, as the previous PR would have applied to 3D cases too from what I can see (and I am under the impression it is explicitly only intended for 2D hulls, as the title of the PR stated).

While developing the modification I inspected the source of the bug (during and in the months following the 2020 SciPy sprint), in this repo, specifically the script partial_coord_offset.py reproduced the bug and then remediated it.

Finally I ran the test suite in pytest, and it broke the majority of the convex hull-related tests and some others (detailed below).

Fixing the IndexError was the first step in @jni's to-do list from 2018, and upon reviewing this I have now also implemented the 2nd step (which involved re-inserting the code lines which Erik had originally removed, so that we get continuous performance and the pre-existing tests will still pass for now):

  • fix the errors.
  • add a keyword argument to convex_hull_image. Naming suggestion: fast_drawing=None, which gets converted to False by default in 0.14 (edit: this would now be 0.19), and True from 0.16 onwards (edit: this would now be 0.21). See the deprecation cycle guidelines from the contributor guide. After 0.16 we can deprecate the argument altogether.
  • add tests that show the correct (pixel-wise) behaviour for fast_drawing=True, and pass fast_drawing=False for existing tests.

So this just leaves the tests to write for the new behaviour, and these can be handled to transition from the old routine to the new one by using the fast_drawing parameter.

Additionally, I will need to add the deprecation to the TODO list, I suggest:

Version 0.21
------------
In ``skimage/morphology/convex_hull.py``, set the `fast_drawing` default value to 'True' in ``convex_hull_image``

Additionally, it should be deprecated by removal in v0.22

Version 0.22
------------
In ``skimage/morphology/convex_hull.py``, remove the `fast_drawing` argument from ``convex_hull_image``

I haven't considered deprecation warnings yet, so will look into this (any guidance welcome but I expect it will be documented in CONTRIBUTING.txt when I look more closely). Can someone confirm I got that right (about the TODO items and which sections they go in, and the need for deprecation warnings) please?

Checklist

  • Docstrings
    • I introduced a single 'internal' funcdef (i.e. whose name is prefixed by a single underscore) and gave a simple docstring explaining what it does but not the extent of a 'public-facing' funcdef in the API like convex_hull_image has.
  • Gallery example in ./doc/examples (already exists, as the feature is not new, just speeds up an existing feature)
    • doc/examples/edges/plot_convex_hull.py
    • Running the example (before adding the deprecation, i.e. with the new routine), the output's result looks perfect (the difference shows the convex hull of the horse fits tightly)
  • Benchmark in ./benchmarks
    • existing feature so expect to be covered in the morphology benchmark
    • I'm not familiar with how these work, though I think I saw the web interface the other month (could someone point me to how I check for a regression/speedup?)
  • Unit test
    • existing feature so already unit tested, but 6 of 8 are failing (and must be rewritten I presume)
    • skimage/morphology/tests/test_convex_hull.py
      • after adding old routine to deprecate: skimage/morphology/tests/test_convex_hull.py ........ [ 83%]
      • before adding the old routine back in: skimage/morphology/tests/test_convex_hull.py F.FF.FFF [ 83%]

Without deprecation:

=================short test summary info ===================
FAILED skimage/feature/tests/test_censure.py::test_keypoints_censure_moon_image_octagon - AssertionError: 
FAILED skimage/feature/tests/test_censure.py::test_keypoints_censure_moon_image_star - AssertionError: 
FAILED skimage/measure/tests/test_regionprops.py::test_all_props_3d - ValueError: too many values to unpack (expected 2)
FAILED skimage/measure/tests/test_regionprops.py::test_feret_diameter_max_3d - ValueError: too many values to unpack (expected 2)
FAILED skimage/measure/tests/test_regionprops.py::test_convex_area - assert 133 == 125
FAILED skimage/measure/tests/test_regionprops.py::test_convex_image - AssertionError: 
FAILED skimage/measure/tests/test_regionprops.py::test_solidity - AssertionError: 
FAILED skimage/morphology/tests/test_convex_hull.py::test_basic - AssertionError: 
FAILED skimage/morphology/tests/test_convex_hull.py::test_qhull_offset_example - AssertionError: 
FAILED skimage/morphology/tests/test_convex_hull.py::test_pathological_qhull_example - AssertionError: 
FAILED skimage/morphology/tests/test_convex_hull.py::test_object - AssertionError: 
FAILED skimage/morphology/tests/test_convex_hull.py::test_non_c_contiguous - ValueError: too many values to unpack (expected 2)
FAILED skimage/morphology/tests/test_convex_hull.py::test_consistent_2d_3d_hulls - ValueError: too many values to unpack (expected 2)
FAILED skimage/morphology/tests/test_grey.py::TestMorphology::test_gray_morphology - AssertionError: 
FAILED skimage/morphology/tests/test_selem.py::TestSElem::test_selem_octagon - AssertionError: 
===================================================================================================== 15 failed, 6297 passed, 31 skipped, 14 warnings

With deprecation:

=========== warnings summary ===========
skimage/filters/rank/generic.py:1
  /home/louis/dev/skimage-pr-new/skimage/filters/rank/generic.py:1: DeprecationWarning: invalid escape sequence \-
    """

../../miniconda/envs/skimage-final-pr/lib/python3.8/importlib/__init__.py:127
  /home/louis/miniconda/envs/skimage-final-pr/lib/python3.8/importlib/__init__.py:127: UserWarning: Viewer requires Qt
    return _bootstrap._gcd_import(name[level:], package, level)

skimage/io/tests/test_tifffile.py::test_tifffile_kwarg_passthrough
  /home/louis/miniconda/envs/skimage-final-pr/lib/python3.8/site-packages/tifffile/tifffile.py:2835: DeprecationWarning: TiffFile: the 'fastij' argument is ignored
    warnings.warn(

skimage/io/tests/test_tifffile.py::test_tifffile_kwarg_passthrough
  /home/louis/miniconda/envs/skimage-final-pr/lib/python3.8/site-packages/tifffile/tifffile.py:2835: DeprecationWarning: TiffFile: the 'multifile' argument is ignored
    warnings.warn(

skimage/io/tests/test_tifffile.py::test_tifffile_kwarg_passthrough
  /home/louis/miniconda/envs/skimage-final-pr/lib/python3.8/site-packages/tifffile/tifffile.py:2835: DeprecationWarning: TiffFile: the 'multifile_close' argument is ignored
    warnings.warn(

skimage/measure/tests/test_fit.py::test_ransac_is_data_valid
skimage/measure/tests/test_fit.py::test_ransac_is_model_valid
skimage/measure/tests/test_fit.py::test_ransac_sample_duplicates
  /home/louis/dev/skimage-pr-new/skimage/measure/fit.py:879: UserWarning: No inliers found. Model not fitted
    warn("No inliers found. Model not fitted")

skimage/restoration/tests/test_restoration.py::test_richardson_lucy_filtered[float32-float32]
skimage/restoration/tests/test_restoration.py::test_richardson_lucy_filtered[float32-float64]
skimage/restoration/tests/test_restoration.py::test_richardson_lucy_filtered[float64-float32]
skimage/restoration/tests/test_restoration.py::test_richardson_lucy_filtered[float64-float64]
  /home/louis/dev/skimage-pr-new/skimage/restoration/deconvolution.py:376: RuntimeWarning: invalid value encountered in true_divide
    relative_blur = np.where(conv < filter_epsilon, 0, image / conv)

skimage/util/tests/test_arraypad.py::TypeError1::test_check_wrong_pad_amount
  /home/louis/miniconda/envs/skimage-final-pr/lib/python3.8/site-packages/numpy/core/_asarray.py:83: VisibleDeprecationWarning: Creating an ndarray from ragged nested sequences (which is a list-or-tuple of lists-or-tuples-or ndarrays with different lengths or shapes) is deprecated. If you meant to do this, you must specify 'dtype=object' when creating the ndarray
    return array(a, dtype, copy=False, order=order)

-- Docs: https://docs.pytest.org/en/stable/warnings.html
================= 6312 passed, 31 skipped, 13 warnings

In conclusion, I need to next work on the tests to advance this PR to a stage at which it can be reviewed but want to submit at this point to initiate the process.

For reviewers

  • Check that the PR title is short, concise, and will make sense 1 year
    later.
  • Check that new functions are imported in corresponding __init__.py.
  • Check that new features, API changes, and deprecations are mentioned in
    doc/release/release_dev.rst.

@pep8speaks
Copy link

Hello @lmmx! Thanks for opening this PR. We checked the lines you've touched for PEP 8 issues, and found:

Line 22:1: E302 expected 2 blank lines, found 1
Line 24:80: E501 line too long (86 > 79 characters)
Line 26:80: E501 line too long (88 > 79 characters)
Line 29:80: E501 line too long (85 > 79 characters)
Line 30:31: E231 missing whitespace after ','
Line 31:31: E231 missing whitespace after ','
Line 33:51: E261 at least two spaces before inline comment
Line 33:80: E501 line too long (87 > 79 characters)
Line 37:80: E501 line too long (83 > 79 characters)
Line 92:80: E501 line too long (90 > 79 characters)
Line 111:80: E501 line too long (80 > 79 characters)
Line 167:80: E501 line too long (83 > 79 characters)
Line 170:80: E501 line too long (90 > 79 characters)

@@ -88,6 +107,11 @@ 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.
fast_drawing : bool or None, optional
If ``True``, use the fast SciPy function ``polygon_perimeter`` to locate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand here, polygon_perimeter is a scikit-image function, not a scipy one? I had the impression that the fast scipy function was binary_fill_holes.

def _apply_partial_offsets(img, coords, offsets):
"""
Apply the offsets only to the non-edge pixels, along with the trivial zero-offset.
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is a bit hard to read, could you please add a minimal example here in the docstring?

@emmanuelle
Copy link
Member

Hi @lmmx thanks a lot for the PR! A couple of points:

  • for benchmarks please take a look at https://scikit-image.org/docs/dev/contribute.html#benchmarks
  • for the technical aspects of deprecation, you're indeed on the right track (items to add to the TODO file, plus warnings to raise in the function. You can also use of the decorators defined in skimage._shared.utils such as deprecate_kwarg).

However, we first need to decide what / if we should deprecate. In #2928 there were a couple of tests on simple and more complex images which showed that there were some differences between the old and the new mode, both for offset_coordinates=True and False. From a test on the simple image

import numpy as np
bw = np.array([
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 1, 0, 0, 0],
    [0, 0, 1, 0, 0, 0, 1, 0, 0],
    [0, 1, 0, 0, 0, 0, 0, 1, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    ])
out_1 = morphology.convex_hull_image(bw).astype(np.uint8)
out_2 = morphology.convex_hull_image(bw, fast_drawing=True, offset_coordinates=False).astype(np.uint8)

I got the impression that your bug fix had fixed the difference between the two modes for offset_coordinates=False. Therefore I wonder if what we should deprecate is offset_coordinates maybe? In the test suite there is no example/test showing how the value of this parameter would change the output with the old mode. Anyway, some simple examples showing the different cases would help for the review.

Copy link
Member

@rfezzani rfezzani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @lmmx! Can you please add a benchmark asserting the acceleration that you claim?

Comment on lines +30 to +34
edge_t, edge_b = [coords[:,0] == lim for lim in (0, row_max)]
edge_l, edge_r = [coords[:,1] == lim for lim in (0, col_max)]
edge_includers = [edge_t, edge_b, edge_l, edge_r]
dummy_edge = np.zeros_like(edge_t, dtype=bool) # all-False so offset always applied
edge_includers.insert(0, dummy_edge)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
edge_t, edge_b = [coords[:,0] == lim for lim in (0, row_max)]
edge_l, edge_r = [coords[:,1] == lim for lim in (0, col_max)]
edge_includers = [edge_t, edge_b, edge_l, edge_r]
dummy_edge = np.zeros_like(edge_t, dtype=bool) # all-False so offset always applied
edge_includers.insert(0, dummy_edge)
edge_includers = np.insert(np.equal(np.repeat(coords, 2, axis=1),
[0, row_max, 0, col_max]),
0, False, axis=1)

If ``True``, use the fast SciPy function ``polygon_perimeter`` to locate
hull perimeter pixels and then fill it in. If ``False``, use the slower
Cython function ``grid_points_in_poly`` to locate convex hull pixels.
If ``None``, default to ``False`` (in future will default to ``True``).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to specify the version when the change will be effective.

Base automatically changed from master to main February 18, 2021 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants