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

Add a convex hull algorithm #160

Merged
merged 16 commits into from
Jul 28, 2024
Merged
2 changes: 2 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version = "0.1.10"

[deps]
CoordinateTransformations = "150eb455-5306-5404-9cee-2592286d6298"
DelaunayTriangulation = "927a84f5-c5f4-47a5-9785-b46e178433df"
ExactPredicates = "429591f6-91af-11e9-00e2-59fbe8cec110"
GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f"
GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326"
Expand All @@ -26,6 +27,7 @@ GeometryOpsProjExt = "Proj"

[compat]
CoordinateTransformations = "0.5, 0.6"
DelaunayTriangulation = "1.0.4"
ExactPredicates = "2.2.8"
FlexiJoins = "0.1.30"
GeoInterface = "1.2"
Expand Down
14 changes: 14 additions & 0 deletions ext/GeometryOpsLibGEOSExt/simple_overrides.jl
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,17 @@ function GO.intersects(::GEOS, geom_a, geom_b)
return LG.intersects(GI.convert(LG, geom_a), GI.convert(LG, geom_b))
end

# ## Convex hull
function GO.convex_hull(::GEOS, geoms)
return LG.convexhull(
LG.MultiPoint(
collect(
GO.flatten(
x -> GI.convert(LG, x),
GI.PointTrait,
geoms
)
)
)
)
end
8 changes: 5 additions & 3 deletions src/GeometryOps.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ module GeometryOps

using GeoInterface
using GeometryBasics
import Tables
using LinearAlgebra, Statistics

import Tables
import GeometryBasics.StaticArrays
import DelaunayTriangulation # for convex hull and triangulation
import ExactPredicates
import Base.@kwdef

using GeoInterface.Extents: Extents
import GeoInterface.Extents: Extents

const GI = GeoInterface
const GB = GeometryBasics
Expand All @@ -28,6 +29,7 @@ include("methods/area.jl")
include("methods/barycentric.jl")
include("methods/buffer.jl")
include("methods/centroid.jl")
include("methods/convex_hull.jl")
include("methods/distance.jl")
include("methods/equals.jl")
include("methods/clipping/predicates.jl")
Expand Down
131 changes: 131 additions & 0 deletions src/methods/convex_hull.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#=
# Convex hull

The [_convex hull_](https://en.wikipedia.org/wiki/Convex_hull) of a set of points is the smallest [**convex**](https://en.wikipedia.org/wiki/Convex_set) polygon that contains all the points.

GeometryOps.jl provides a number of methods for computing the convex hull of a set of points, usually
linked to other Julia packages.

For now, we expose one algorithm, [MonotoneChainMethod](@ref), which uses the [DelaunayTriangulation.jl](https://github.com/JuliaGeometry/DelaunayTriangulation.jl)
package. The `GEOS()` interface also supports convex hulls.

Future work could include other algorithms, such as [Quickhull.jl](https://github.com/augustt198/Quickhull.jl), or similar, via package extensions.


## Example

### Simple hull
```@example simple
import GeometryOps as GO, GeoInterface as GI
using CairoMakie # to plot

points = randn(GO.Point2f, 100)
f, a, p = plot(points; label = "Points")
hull_poly = GO.convex_hull(points)
lines!(a, hull_poly; label = "Convex hull", color = Makie.wong_colors()[2])
axislegend(a)
f
```

## Convex hull of the USA
```@example usa
import GeometryOps as GO, GeoInterface as GI
using CairoMakie # to plot
using NaturalEarth # for data

all_adm0 = naturalearth("admin_0_countries", 110)
usa = all_adm0.geometry[findfirst(==("USA"), all_adm0.ADM0_A3)]
f, a, p = lines(usa)
lines!(a, GO.convex_hull(usa); color = Makie.wong_colors()[2])
f
```

## Investigating the winding order

The winding order of the monotone chain method is counterclockwise,
while the winding order of the GEOS method is clockwise.

GeometryOps' convexity detection says that the GEOS hull is convex,
while the monotone chain method hull is not. However, they are both going
over the same points (we checked), it's just that the winding order is different.

In reality, both sets are convex, but we need to fix the GeometryOps convexity detector
([`isconcave`](@ref))!

We may also decide at a later date to change the returned winding order of the polygon, but
most algorithms are robust to that, and you can always [`fix`](@ref) it...

```@example windingorder
import GeoInterface as GI, GeometryOps as GO, LibGEOS as LG
using CairoMakie # to plot

points = rand(Point2{Float64}, 100)
go_hull = GO.convex_hull(GO.MonotoneChainMethod(), points)
lg_hull = GO.convex_hull(GO.GEOS(), points)

fig = Figure()
a1, p1 = lines(fig[1, 1], go_hull; color = 1:GI.npoint(go_hull), axis = (; title = "MonotoneChainMethod()"))
a2, p2 = lines(fig[2, 1], lg_hull; color = 1:GI.npoint(lg_hull), axis = (; title = "GEOS()"))
cb = Colorbar(fig[1:2, 2], p1; label = "Vertex number")
fig
```

## Implementation

=#

"""
convex_hull([method], geometries)

Compute the convex hull of the points in `geometries`.
Returns a `GI.Polygon` representing the convex hull.

Note that the polygon returned is wound counterclockwise
as in the Simple Features standard by default. If you
choose GEOS, the winding order will be inverted.

!!! warning
This interface only computes the 2-dimensional convex hull!

For higher dimensional hulls, use the relevant package (Qhull.jl, Quickhull.jl, or similar).
"""
function convex_hull end

"""
MonotoneChainMethod()

This is an algorithm for the [`convex_hull`](@ref) function.

Uses [`DelaunayTriangulation.jl`](https://github.com/JuliaGeometry/DelaunayTriangulation.jl) to compute the convex hull.
This is a pure Julia algorithm which provides an optimal Delaunay triangulation.

See also [`convex_hull`](@ref)
"""
struct MonotoneChainMethod end

# GrahamScanMethod, etc. can be implemented in GO as well, if someone wants to.
# If we add an extension on Quickhull.jl, then that would be another algorithm.

convex_hull(geometries) = convex_hull(MonotoneChainMethod(), geometries)

# TODO: have this respect the CRS by pulling it out of `geometries`.
function convex_hull(::MonotoneChainMethod, geometries)
# Extract all points as tuples. We have to collect and allocate
Copy link
Contributor

@DanielVandH DanielVandH Jun 13, 2024

Choose a reason for hiding this comment

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

Is this true? You can provide whatever point types you like so long as you extend the interfaces (which are hopefully sufficiently documented) from DelaunayTriangulation.jl appropriately. I give a complete example here https://juliageometry.github.io/DelaunayTriangulation.jl/dev/tutorials/custom_primitive/, maybe something goes wrong in convex_hull that I'm not aware of?

Highlighted the wrong part of the text for this comment but I'm referring to the need to flatten and collect the points

Copy link
Member Author

Choose a reason for hiding this comment

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

We have to flatten anyway to get a vector, and getting x and y values from C pointers is pretty bad for performance - best to do it once. I can update the comment with better verbiage, though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Plus, we'd have to extend the interface for every GeoInterface compatible type, which would probably mean either piracy or changes on the DelaunayTriangulation end. (Though if you do want to allow GeoInterface as a fallback it would be pretty easy I think).

Copy link
Contributor

Choose a reason for hiding this comment

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

We have to flatten anyway to get a vector, and getting x and y values from C pointers is pretty bad for performance - best to do it once. I can update the comment with better verbiage, though.

I have no preference either way just wanted to see if you were aware (I wasn't aware of the need for getting the values from C pointers, so fair enough). The verbiage is alright

Plus, we'd have to extend the interface for every GeoInterface compatible type, which would probably mean either piracy or changes on the DelaunayTriangulation end. (Though if you do want to allow GeoInterface as a fallback it would be pretty easy I think).

It would probably be welcome as a package extension in DelaunayTriangulation. I'm not familiar with the interfaces though, so I probably won't be delving into that. Just leaving the code as is is fine anyway, it works and would be fast enough :).

Copy link
Member Author

Choose a reason for hiding this comment

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

It would probably just be:

DT.getx(p) = DT.getx(GI.trait(p), p)
DT.gety(p) = DT.gety(GI.trait(p), p)
DT.number_type(p) = DT.number_type(GI.trait(p), p)

DT.getx(::GI.PointTrait, p) = GI.x(p)
DT.gety(::GI.PointTrait, p) = GI.y(p)
DT.number_type(::GI.PointTrait, p) = typeof(GI.x(p))

# here, because DelaunayTriangulation only accepts vectors of
# point-like geoms.

# Cleanest would be to use the iterable from GO.flatten directly,
# but that would require us to implement the convex hull algorithm
# directly.

# TODO: create a specialized method that extracts only the information
# required, GeometryBasics points can be passed through directly.
points = collect(flatten(tuples, GI.PointTrait, geometries))
# Compute the convex hull using DelTri (shorthand for DelaunayTriangulation.jl).
hull = DelaunayTriangulation.convex_hull(points)
# Convert the result to a `GI.Polygon` and return it.
# View would be more efficient here, but re-allocating
# is cleaner.
point_vec = DelaunayTriangulation.get_points(hull)[DelaunayTriangulation.get_vertices(hull)]
return GI.Polygon([GI.LinearRing(point_vec)])
end
42 changes: 42 additions & 0 deletions test/methods/convex_hull.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Test
import GeoInterface as GI, GeometryOps as GO, LibGEOS as LG

@testset "Basic example" begin
points = tuple.(rand(100), rand(100))
hull = GO.convex_hull(points)
@test !GO.isconcave(hull) skip=true # TODO: fix
@test !GO.isclockwise(GI.getexterior(hull)) # exterior should be ccw
@test all(x -> GO.covers(hull, x), points) # but the orientation is right
# Test against LibGEOS, by testing that all the points are the same
# This is robust against winding order and starting at a different point, etc.
@test isempty(
setdiff(
collect(GO.flatten(GO.tuples, GI.PointTrait, hull)),
collect(GO.flatten(GO.tuples, GI.PointTrait, GO.convex_hull(GEOS(), points)))
)
)
end

@testset "Duplicated points" begin
points = tuple.(rand(100), rand(100))
@test_nowarn hull = GO.convex_hull(vcat(points, points))
single_hull = GO.convex_hull(points)
double_hull = GO.convex_hull(vcat(points, points))

@test GO.equals(GI.getexterior(single_hull), GI.getexterior(double_hull))
@test !GO.isconcave(double_hull) skip=true # TODO: fix
@test !GO.isclockwise(GI.getexterior(double_hull)) # exterior should be ccw
@test all(x -> GO.covers(single_hull, x), points)
@test all(x -> GO.covers(double_hull, x), points)
# Test against LibGEOS, by testing that all the points are the same
# This is robust against winding order and starting at a different point, etc.
@test isempty(
setdiff(
collect(GO.flatten(GO.tuples, GI.PointTrait, double_hull)),
collect(GO.flatten(GO.tuples, GI.PointTrait, GO.convex_hull(GEOS(), points)))
)
)
end

# The rest of the tests are handled in DelaunayTriangulation.jl, this is simply testing
# that the methods work as expected.
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const GO = GeometryOps
@testset "Barycentric coordinate operations" begin include("methods/barycentric.jl") end
@testset "Orientation" begin include("methods/orientation.jl") end
@testset "Centroid" begin include("methods/centroid.jl") end
@testset "Convex Hull" begin include("methods/convex_hull.jl") end
@testset "DE-9IM Geom Relations" begin include("methods/geom_relations.jl") end
@testset "Distance" begin include("methods/distance.jl") end
@testset "Equals" begin include("methods/equals.jl") end
Expand Down
Loading