Skip to content

Implementating Concavehull#1328

Open
souma4 wants to merge 16 commits into
JuliaGeometry:masterfrom
souma4:concavehull
Open

Implementating Concavehull#1328
souma4 wants to merge 16 commits into
JuliaGeometry:masterfrom
souma4:concavehull

Conversation

@souma4
Copy link
Copy Markdown
Contributor

@souma4 souma4 commented Mar 1, 2026

Implementing a k nearest neighbors approach for Concavehulls #497. The paper referenced in the issue is primarily for combining offset points and is quite a bit more involved than this paper. This looks very similar to Jarvis March, just with an added check to prevent self intersections. I refactored some of the convex hull tests to make sure we are properly testing hulls in general, but not holding concave hulls to the same standard as convex hulls.

I am curious, though, The tests starting at L43 and L75 give different polygons between concave and convex hulls. The convex hull hugs tightly to the points, but the concave hull smooths them. To me it seems like a valid solution (plus, concave hulls don't have a singular correct solution), but it seems sort of weird that the area of the convex hull is less than the area of the concave hull. Let me know what you think.

edit: Ah, I didn't catch it's just defaulting to convex hull because it fails to get some points inside the polygon. More work to be done. Will mark for review when it gets solved

@github-actions
Copy link
Copy Markdown
Contributor

ghost commented Mar 1, 2026

Benchmark Results (Julia vlts)

Time benchmarks
master ea2c8c3... master / ea2c8c3...
clipping/SutherlandHodgman 3.63 ± 0.23 μs 3.65 ± 0.2 μs 0.995 ± 0.083
discretization/simplexify 0.671 ± 0.022 ms 0.675 ± 0.022 ms 0.995 ± 0.046
intersection/ray-triangle 0.05 ± 0 μs 0.05 ± 0 μs 1 ± 0
intersects/triangle-tetrahedron 0.411 ± 0.019 μs 0.41 ± 0.01 μs 1 ± 0.052
sideof/ring/large 6.53 ± 0.011 μs 6.53 ± 0.01 μs 1 ± 0.0023
sideof/ring/small 0.05 ± 0 μs 0.05 ± 0 μs 1 ± 0
topology/half-edge 2.73 ± 0.049 ms 2.71 ± 0.041 ms 1.01 ± 0.024
winding/mesh 16.1 ± 0.1 ms 16.1 ± 0.15 ms 1 ± 0.011
time_to_load 1.2 ± 0.0027 s 1.19 ± 0.014 s 1.01 ± 0.012
Memory benchmarks
master ea2c8c3... master / ea2c8c3...
clipping/SutherlandHodgman 0.053 k allocs: 4.97 kB 0.053 k allocs: 4.97 kB 1
discretization/simplexify 18.1 k allocs: 1.92 MB 18.1 k allocs: 1.92 MB 1
intersection/ray-triangle 0 allocs: 0 B 0 allocs: 0 B
intersects/triangle-tetrahedron 3 allocs: 0.344 kB 3 allocs: 0.344 kB 1
sideof/ring/large 0 allocs: 0 B 0 allocs: 0 B
sideof/ring/small 0 allocs: 0 B 0 allocs: 0 B
topology/half-edge 18.1 k allocs: 2.92 MB 18.1 k allocs: 2.92 MB 1
winding/mesh 23.2 k allocs: 3.08 MB 23.2 k allocs: 3.08 MB 1
time_to_load 0.153 k allocs: 14.5 kB 0.153 k allocs: 14.5 kB 1

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.17%. Comparing base (063679a) to head (ea2c8c3).
⚠️ Report is 11 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1328      +/-   ##
==========================================
+ Coverage   85.79%   86.17%   +0.37%     
==========================================
  Files         200      201       +1     
  Lines        6436     6497      +61     
==========================================
+ Hits         5522     5599      +77     
+ Misses        914      898      -16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions
Copy link
Copy Markdown
Contributor

ghost commented Mar 1, 2026

Benchmark Results (Julia v1)

Time benchmarks
master ea2c8c3... master / ea2c8c3...
clipping/SutherlandHodgman 2.77 ± 0.35 μs 2.79 ± 0.38 μs 0.992 ± 0.18
discretization/simplexify 0.464 ± 0.065 ms 0.474 ± 0.067 ms 0.979 ± 0.19
intersection/ray-triangle 0.05 ± 0.01 μs 0.05 ± 0 μs 1 ± 0.2
intersects/triangle-tetrahedron 0.401 ± 0.011 μs 0.401 ± 0.01 μs 1 ± 0.037
sideof/ring/large 7.69 ± 0.01 μs 7.69 ± 0.031 μs 1 ± 0.0042
sideof/ring/small 0.06 ± 0.01 μs 0.06 ± 0.01 μs 1 ± 0.24
topology/half-edge 3.05 ± 0.33 ms 3.03 ± 0.28 ms 1.01 ± 0.14
winding/mesh 14.1 ± 0.26 ms 13.9 ± 0.25 ms 1.01 ± 0.026
time_to_load 1.05 ± 0.0048 s 1.05 ± 0.011 s 1 ± 0.012
Memory benchmarks
master ea2c8c3... master / ea2c8c3...
clipping/SutherlandHodgman 0.064 k allocs: 5.55 kB 0.064 k allocs: 5.55 kB 1
discretization/simplexify 0.0362 M allocs: 1.93 MB 0.0362 M allocs: 1.93 MB 1
intersection/ray-triangle 0 allocs: 0 B 0 allocs: 0 B
intersects/triangle-tetrahedron 4 allocs: 0.266 kB 4 allocs: 0.266 kB 1
sideof/ring/large 0 allocs: 0 B 0 allocs: 0 B
sideof/ring/small 0 allocs: 0 B 0 allocs: 0 B
topology/half-edge 25.9 k allocs: 3.17 MB 25.9 k allocs: 3.17 MB 1
winding/mesh 0.0413 M allocs: 3.08 MB 0.0413 M allocs: 3.08 MB 1
time_to_load 0.149 k allocs: 11.1 kB 0.149 k allocs: 11.1 kB 1

@souma4 souma4 marked this pull request as ready for review March 4, 2026 19:20
@juliohm
Copy link
Copy Markdown
Member

juliohm commented Mar 19, 2026

@souma4 what is the status of this one? Is it ready for review?

Comment thread src/hulls/concave.jl Outdated
"""
struct Concave <: HullMethod end

function hull(points, ::Concave; k=3)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Notice that the design we follow is one in which every parameter of the method is stored in the method object itself. The k parameter should be a field of the Concave struct. Also, try to be more specific with the method name, perhaps KnnJarvisMarch?

Can't we adjust our current JarvisMarch to include a k parameter instead of repeating a similar implementation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As far as your first suggestion, that's easy to swap in.

For the second, Jarvis March works by performing a global ordering of points by "right hand turns." What do you think of making a more generic JarvisHull that can be used to dispatch the k-th jarvis march algorithm, but KnnJarvisMarch is a type that defaults to k=3, and JarvisMarch is just a method dispatch for hull where points are used to set k?

Something to the effect of

abstract type JarvisHull <: HullMethod end # I can see removing this and Making Knn JarvisMarch a subtype of HullMethod. That would avoid introducing a new abstract type. Just might make reduce extensibility.
struct JarvisMarch <: JarvisHull end
struct KnnJarvisMarch <: JarvisHull
  k::Int
  KnnJarvisMarch() = new(3)
  KnnJarvisMarch(k::Int) = new(k)
end

function hull(points, method::J) where J <: JarvisHull #if we remove the abstract type, then just replace J with the concrete type
 # k-nearest implementation that I propose
 # ...
end

hull(points, ::JarvisMarch) = hull(points, KnnJarvisMarch(length(points))

Let me know what you think

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would keep it as simple as possible. From what I can tell we only need to generalize the implementation of JarvisMarch to handle k. By default we can set k=nothing and update it inside the hull call to match the current behavior. If the generalization to handle k implies worst performance when k=npoints, then we can consider alternative designs.

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented Mar 19, 2026

@juliohm I still want to simplify the internal logic. The check for self intersection is a bit messy right now so I don't think it's ready. And with your other feedback, it might be worth slotting this Knn approach into jarvis.jl and deleting the concave.jl file.

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented Mar 28, 2026

@juliohm This should be good for review now. I refactored the main jarvis algorithm to dispatch on different JarvisMarch{k} methods. if on Nothing it's same as original implementation (which JarvisMarch() creates). If on an integer, then it dispatches the knearest approach. If a valid hull cannot be made at a given k it iterates through k's until a valid one is found. According to the profiler, most time is spent checking if a proposed "next segment" intersects the current hull segments. Without that, it's very easy to get inappropriate early termination.

@juliohm
Copy link
Copy Markdown
Member

juliohm commented Mar 30, 2026

Thank you @souma4. I will try to find some time to review the PR over the week.

Copy link
Copy Markdown
Member

@juliohm juliohm left a comment

Choose a reason for hiding this comment

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

Hi @souma4 , attached is a first round of review.

I believe we still have ways to simplify the implementation and make it more straightforward.

Comment thread src/hulls/jarvis.jl Outdated
# perform primary Jarvis march loop
ℐ = _jarvisloop(method, points, p, n, i, O, A)
# if no hull is found, return convex hull as fallback
isnothing(ℐ) && return convexhull(p)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can't call convexhull, which is a higher-level abstraction inside hull, which is the low-level implementation. I also don't understand why is it possible that the indices are nothing. Whenever we call hull with a set of points, the result of the Jarvis loop should be valid, right?

Comment thread src/hulls/jarvis.jl Outdated
end

# concave hull
function _jarvisloop(method::JarvisMarch{I}, points, p, n, i, O, A) where {I<:Integer}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

From the paper I recall that the only difference between JarvisMarch{Nothing} and JarvisMarch{K} the the set of candidate indices. In the former, we consider all remaining candidates, whereas in the latter we consider the K-nearest candidates.

Couldn't we simply preserve the original implementation, with minimal changes, by just introducing a helper function to specialize the set of candidates?

jarviscandidates(::JarvisMarch{Nothing}, ...) = # all remaining indices
jarviscandidates(::JarvisMarch{<:Integer}, ...) = # K-nearest indices

The less we modify the original implementation, the less likely we introduce bugs.

If these functions need an extra state (e.g., KNN searcher), then this state could be instantiated out of the loop, and passed to the function:

searcher = nothing
searcher = KNearestSearch(...)

jarviscandidates(searcher::Nothing, ...)
jarviscandidates(searcher::KNearest, ...)

Alternatively, you could select a function before the Jarvis loop based on K to avoid extra complications:

# define candidates function
candidates = if k isa Integer
  # instantiate searcher once
  searcher = KNearestSearch(...)
  # return function that retrieves k-nearest candidates
  i -> search(points[i], searcher)
else # k is nothing
  # return function that retrieves remaining candidates
  i -> setdiff(1:n, [i])
end

# now candidates can be used regardless of K
js = candidates(i)

Copy link
Copy Markdown
Contributor Author

@souma4 souma4 Apr 4, 2026

Choose a reason for hiding this comment

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

Possibly. The biggest issue would be the self intersection check and reinserting the starting point to the KNearestSearch mask, but there is probably a solution. I'll work on that.

Comment thread src/hulls/jarvis.jl Outdated
# initial state for retries
i₀, O₀, A₀ = i, O, A

# try increasing k until valid hull found
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am concerned that you are implementing more than just Jarvis March with fixed K. This meta algorithm for increasing k is necessary or is it another layer of abstraction to enforce some kind of expected result?

If for some k and some specific points the algorithm is not valid (e.g., infinite loop), we should just return an error. Any additional logic to attempt multiple k could be made available in a separate method like JarvisMarchManyKAttempts or something.

Copy link
Copy Markdown
Contributor Author

@souma4 souma4 Apr 4, 2026

Choose a reason for hiding this comment

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

This is so that people don't need to tweak input k if their k is too low to find a valid result. we could easily do a wrap around the function for many k attempts. I'll roll with fixed k

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented Apr 20, 2026

Sorry for the commit wall. I screwed up a rebase to the newest version of Meshes. Nice thing is this no longer contains commits from the integral patches.

This is ready for review and I've left some comments to help in the review.

@juliohm
Copy link
Copy Markdown
Member

juliohm commented Apr 20, 2026

Tests are failing, so it is not ready for review yet.

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented Apr 20, 2026

huh... I really thought it would pass all tests, but it's some weird issue where infinite loops are occuring when the inputs are random. I'll figure it out.

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented Apr 22, 2026

Okay, now we're good for a review

Copy link
Copy Markdown
Member

@juliohm juliohm 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 @souma4 for the updated PR. It is looking much cleaner now.

Try to define/document the 2 or 3 main operations that change in the presence or absence of k. With these operations well-defined, implement the corresponding methods.

My current understanding is that we only need to define the jarvissearcher operation, which creates a searcher or a dummy (nothing) object, the jarviscandidates operation, that takes the searcher and returns indices, and some sort of jarvisupdate operation.

Comment thread src/hulls/jarvis.jl Outdated
Comment on lines +35 to +61
AdaptiveJarvisMarch(k)

Compute the concave hull of a set of points or geometries using an adaptive
version of the Jarvis's march algorithm. The algorithm will iteratively increase `k` until all points are in the hull

see also `JarvisMarch` for creating a `convexhull` and `concavehull` for default
concave hulls starting with `k = 3`.

"""
struct AdaptiveJarvisMarch{K<:JarvisMarch{<:Integer}} <: HullMethod
march::K
end
AdaptiveJarvisMarch() = AdaptiveJarvisMarch(JarvisMarch(3))
AdaptiveJarvisMarch(k::I) where {I<:Integer} = AdaptiveJarvisMarch(JarvisMarch(k))

function hull(points, method::AdaptiveJarvisMarch)
k = method.march.k
pointsꜝ = unique(points)
for kᵢ in k:length(pointsꜝ)
chul = hull(points, JarvisMarch(kᵢ))
# validate we found a hull that contains all points, if so return it, otherwise increase k and try again
isnothing(chul) && continue
all(points .∈ Ref(chul)) && return chul
end
# otherwise, fallback to convex hull
hull(points, JarvisMarch())
end
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This algorithm is too simple to deserve a struct. Users will likely need different strategies for picking k and interrupting the loop. Please remove AdaptiveJarvisMarch from the PR. We can think about it later if really necessary.

Comment thread src/hulls/jarvis.jl Outdated
Comment on lines +88 to +89
# if no candidates exist (k too large), fallback to convex hull
isnothing(𝒞) && return hull(points, JarvisMarch())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This condition should happen in the beginning, right after the corner cases with n==1, n==2, n==3. You can simply check if k >= n and call the convex hull case.

Comment thread src/hulls/jarvis.jl
Comment thread src/hulls/jarvis.jl Outdated
# corner cases
n == 1 && return p[1]
n == 2 && return Segment(p[1], p[2])
n == 3 && return PolyArea(p)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this case already covered by the general algorithm? Is this extra line added for performance?

Comment thread src/hulls/jarvis.jl Outdated

# candidates for next point
𝒞 = [1:(i - 1); (i + 1):n]
candidates = jarviscandidates(searcher, 𝒞, n, p, i, i, start)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It is much easier to understand the proposed changes if the variable names are not changed. Before this PR, we called 𝒞 the pool of candidate indices. Please preserve the name.

Comment thread src/hulls.jl Outdated

_gconcavehull(geoms) = _pconcavehull(p for g in geoms for p in boundarypoints(g))

_pconcavehull(points) = hull(points, AdaptiveJarvisMarch()) # default k = 3 for concave hulls
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this internal _pconcavehull function could hold the heuristic you called AdaptiveJarvisMarch. The implementation should live here though.

Comment thread test/hulls.jl Outdated
# can AdaptiveJarvisMarch(Integer) be constructed with an integer k and work
@test hull(cart.([(0, 0), (1, 0), (1, 1), (0, 1), (0.5, -1)]), AdaptiveJarvisMarch(3)) isa PolyArea

for method in [GrahamScan(), JarvisMarch(), AdaptiveJarvisMarch()]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please remove the AdaptiveJarvisMarch from this testset and any related change. We need a dedicated test set for concave hulls.

@juliohm
Copy link
Copy Markdown
Member

juliohm commented May 25, 2026

@souma4 when do you think you will have some time to continue the work on this PR? :)

@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented May 25, 2026

This week I'll have the updated version pushed.

souma4 added 3 commits May 25, 2026 10:48
…d some fn names to be more descriptive. Made variable names more consistent to original implementation
… JarvisMarch{Int} within existing tests, but ignore it for cases where invalid hulls are expected.
@souma4
Copy link
Copy Markdown
Contributor Author

souma4 commented May 25, 2026

I think this is good for a review.

Comment thread src/hulls/jarvis.jl
Comment on lines +19 to +21
see `concavehull` for a version that iteratively increases `k` until all
points are in the hull, which is useful when an effective `k` is not known prior.
This gurantees correctness.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The concavehull is a higher-level function, and shouldn't be mentioned in the docstring of the implementation. Please remove this paragraph.

Comment thread src/hulls/jarvis.jl
Comment on lines +13 to +14
If `k` is provided, the algorithm will attempt to compute a concave hull using k
nearest neighbors. However, the algorithm is not guaranteed to succeed for any particular `k`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens when the algorithm does not succeed? That would be useful information to add to the docstring.

Comment thread src/hulls/jarvis.jl
Comment on lines +29 to +33
struct JarvisMarch{K} <: HullMethod
k::K
end
JarvisMarch() = JarvisMarch{Nothing}(nothing)
JarvisMarch(k::I) where {I<:Integer} = JarvisMarch{I}(k)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
struct JarvisMarch{K} <: HullMethod
k::K
end
JarvisMarch() = JarvisMarch{Nothing}(nothing)
JarvisMarch(k::I) where {I<:Integer} = JarvisMarch{I}(k)
struct JarvisMarch{K} <: HullMethod
k::K
end
JarvisMarch() = JarvisMarch{Nothing}(nothing)
JarvisMarch(k::I) where {I<:Integer} = JarvisMarch{I}(k)

Comment thread src/hulls/jarvis.jl
n == 1 && return p[1]
n == 2 && return Segment(p[1], p[2])
kₒ = method.k
isnothing(kₒ) || kₒ ≥ n && return hull(points, JarvisMarch())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need proper error handling when k > n. If the user specifies k manually, he/she should know that it can't be larger than n.

Comment thread src/hulls/jarvis.jl
jarvissearcher(k, n, p) = nothing, nothing, 1:n

function jarvissearcher(kₒ::T, n, p) where {T<:Integer}
k = min(max(kₒ, T(3)), n)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel that you constantly mix side effects from different auxiliary functions with side effects that are part of the main function. Why the jarvissearcher auxiliary function needs to correct the input k? Isn't that the job of the main function? If you are asking for a searcher with invalid k, and correcting the k inside the auxiliary function, it is really hard to predict what is going to happen later on in the main function.

Try to improve encapsulation. In this case, you should probably handle the corner cases with k=3 and k > n in the main function before calling jarvissearcher.

Comment thread src/hulls.jl

Concave hull of `object`.
"""
# function concavehull end
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This line is commented out.

Comment thread src/hulls.jl
Comment on lines +106 to +117
function _pconcavehull(points)
pointsꜝ = unique(points)
n = length(pointsꜝ) - 1
for k in 3:n
chul = hull(pointsꜝ, JarvisMarch(k))
# validate we found a hull that contains all points, if so return it, otherwise increase k and try again
isnothing(chul) && continue
all(pointsꜝ .∈ Ref(chul)) && return chul
end
# otherwise, fallback to convex hull
hull(pointsꜝ, JarvisMarch())
end
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please describe the heuristic that was implemented in the docstring. You're increasing k from 3 to n until all points are inside of the resulting polygonal area. Is there another heuristic to consider in practical applications? For example, select k such that the area is minimized? Select k such that all vertices of the resulting polygon are at least d meters apart? Should we give users the freedom to pick their own heuristic? If yes, I suggest that we add this concavehull utility in a separate PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants