From 2ab27e4210daa5e88c5d07b6ddd6ab0e971236d2 Mon Sep 17 00:00:00 2001 From: Skylar A Gering Date: Mon, 2 Oct 2023 12:29:53 -0700 Subject: [PATCH] Make centroid use GeoInterface (#19) * Rewrite centroid using GeoInterface * Add documentation * Working on convenience functions * Start adding centroid tests * Update post ring tests * Remove libgeos dependency * Add random polygon generator * Add more tests and refine * Switch return type to be tuple * Update docs to match output type --- Project.toml | 8 +- src/methods/centroid.jl | 295 ++++++++++++++++++++++++-------- src/methods/signed_area.jl | 2 +- src/utils.jl | 5 - test/data/polygon_generation.jl | 66 +++++++ test/methods/centroid.jl | 110 ++++++++++++ test/runtests.jl | 9 +- 7 files changed, 412 insertions(+), 83 deletions(-) create mode 100644 test/data/polygon_generation.jl create mode 100644 test/methods/centroid.jl diff --git a/Project.toml b/Project.toml index 527dd0464..318c474d8 100644 --- a/Project.toml +++ b/Project.toml @@ -18,9 +18,15 @@ julia = "1.3" [extras] ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3" +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" GeoFormatTypes = "68eda718-8dee-11e9-39e7-89f7f65f511f" GeoJSON = "61d90e0f-e114-555e-ac52-39dfb47a3ef9" +LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["ArchGDAL", "GeoFormatTypes", "GeoJSON", "Test"] +test = [ + "ArchGDAL", "Distributions", "GeoFormatTypes", "GeoJSON", "LibGEOS", + "Random", "Test", +] diff --git a/src/methods/centroid.jl b/src/methods/centroid.jl index cfd37098d..03dbc6798 100644 --- a/src/methods/centroid.jl +++ b/src/methods/centroid.jl @@ -1,86 +1,231 @@ # # Centroid -export centroid - -# These are all GeometryBasics.jl methods so far. -# They need to be converted to GeoInterface. - -# The reason that there is a `centroid_and_signed_area` function, -# is because in conputing the centroid, you end up computing the signed area. - -# In some computational geometry applications this may be a useful -# source of efficiency, so I added it here. - -# However, it's totally fine to ignore this and not have this code path. -# We simply need to decide on this. - -function centroid(ls::GB.LineString{2, T}) where T - centroid = Point{2, T}(0) - total_area = T(0) - if length(ls) == 1 - return sum(ls[1])/2 +export centroid, centroid_and_length, centroid_and_area + +#= +## What is the centroid? + +The centroid is the geometric center of a line string or area(s). Note that +the centroid does not need to be inside of a concave area. + +Further note that by convention a line, or linear ring, is calculated by +weighting the line segments by their length, while polygons and multipolygon +centroids are calculated by weighting edge's by their 'area components'. + +To provide an example, consider this concave polygon in the shape of a 'C': +```@example cshape +using GeometryOps +using GeometryOps.GeometryBasics +using Makie +using CairoMakie + +cshape = Polygon([ + Point(0,0), Point(0,3), Point(3,3), Point(3,2), Point(1,2), + Point(1,1), Point(3,1), Point(3,0), Point(0,0), +]) +f, a, p = poly(cshape; axis = (; aspect = DataAspect())) +``` +Let's see what the centroid looks like (plotted in red): +```@example cshape +cent = centroid(cshape) +scatter!(a, GI.x(cent), GI.y(cent), color = :red) +f +``` + +## Implementation + +This is the GeoInterface-compatible implementation. + +First, we implement a wrapper method that dispatches to the correct +implementation based on the geometry trait. This is also used in the +implementation, since it's a lot less work! + +Note that if you call centroid on a LineString or LinearRing, the +centroid_and_length function will be called due to the weighting scheme +described above, while centroid_and_area is called for polygons and +multipolygons. However, centroid_and_area can still be called on a +LineString or LinearRing when they are closed, for example as the interior hole +of a polygon. + +The helper functions centroid_and_length and centroid_and_area are made +availible just in case the user also needs the area or length to decrease +repeat computation. +=# +""" + centroid(geom)::Tuple{T, T} + +Returns the centroid of a given line segment, linear ring, polygon, or +mutlipolygon. +""" +centroid(geom) = centroid(GI.trait(geom), geom) + +""" + centroid( + trait::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, + )::Tuple{T, T} + +Returns the centroid of a line string or linear ring, which is calculated by +weighting line segments by their length by convention. +""" +centroid( + trait::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, +) = centroid_and_length(trait, geom)[1] + +""" + centroid(trait, geom)::Tuple{T, T} + +Returns the centroid of a polygon or multipolygon, which is calculated by +weighting edges by their `area component` by convention. +""" +centroid(trait, geom) = centroid_and_area(trait, geom)[1] + +""" + centroid_and_length(geom)::(::Tuple{T, T}, ::Real) + +Returns the centroid and length of a given line/ring. Note this is only valid +for line strings and linear rings. +""" +centroid_and_length(geom) = centroid_and_length(GI.trait(geom), geom) + +""" + centroid_and_area( + ::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, + )::(::Tuple{T, T}, ::Real) + +Returns the centroid and area of a given geom. +""" +centroid_and_area(geom) = centroid_and_area(GI.trait(geom), geom) + +""" + centroid_and_length(geom)::(::Tuple{T, T}, ::Real) + +Returns the centroid and length of a given line/ring. Note this is only valid +for line strings and linear rings. +""" +function centroid_and_length( + ::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, +) + T = typeof(GI.x(GI.getpoint(geom, 1))) + # Initialize starting values + xcentroid = T(0) + ycentroid = T(0) + length = T(0) + point₁ = GI.getpoint(geom, 1) + # Loop over line segments of line string + for point₂ in GI.getpoint(geom) + # Calculate length of line segment + length_component = sqrt( + (GI.x(point₂) - GI.x(point₁))^2 + + (GI.y(point₂) - GI.y(point₁))^2 + ) + # Accumulate the line segment length into `length` + length += length_component + # Weighted average of line segment centroids + xcentroid += (GI.x(point₁) + GI.x(point₂)) * (length_component / 2) + ycentroid += (GI.y(point₁) + GI.y(point₂)) * (length_component / 2) + #centroid = centroid .+ ((point₁ .+ point₂) .* (length_component / 2)) + # Advance the point buffer by 1 point to move to next line segment + point₁ = point₂ end - - p0 = ls[1][1] - - for i in 1:(length(ls)-1) - p1 = ls[i][2] - p2 = ls[i+1][2] - area = signed_area(p0, p1, p2) - centroid = centroid .+ Point{2, T}((p0[1] + p1[1] + p2[1])/3, (p0[2] + p1[2] + p2[2])/3) * area - total_area += area - end - return centroid ./ total_area + xcentroid /= length + ycentroid /= length + return (xcentroid, ycentroid), length end -# a more optimized function, so we only calculate signed area once! -function centroid_and_signed_area(ls::GB.LineString{2, T}) where T - centroid = Point{2, T}(0) - total_area = T(0) - if length(ls) == 1 - return sum(ls[1])/2 - end - - p0 = ls[1][1] - - for i in 1:(length(ls)-1) - p1 = ls[i][2] - p2 = ls[i+1][2] - area = signed_area(p0, p1, p2) - centroid = centroid .+ Point{2, T}((p0[1] + p1[1] + p2[1])/3, (p0[2] + p1[2] + p2[2])/3) * area - total_area += area +""" + centroid_and_area( + ::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, + )::(::Tuple{T, T}, ::Real) + +Returns the centroid and area of a given a line string or a linear ring. +Note that this is only valid if the line segment or linear ring is closed. +""" +function centroid_and_area( + ::Union{GI.LineStringTrait, GI.LinearRingTrait}, + geom, +) + T = typeof(GI.x(GI.getpoint(geom, 1))) + # Check that the geometry is closed + @assert( + GI.getpoint(geom, 1) == GI.getpoint(geom, GI.ngeom(geom)), + "centroid_and_area should only be used with closed geometries" + ) + # Initialize starting values + xcentroid = T(0) + ycentroid = T(0) + area = T(0) + point₁ = GI.getpoint(geom, 1) + # Loop over line segments of linear ring + for point₂ in GI.getpoint(geom) + area_component = GI.x(point₁) * GI.y(point₂) - + GI.x(point₂) * GI.y(point₁) + # Accumulate the area component into `area` + area += area_component + # Weighted average of centroid components + xcentroid += (GI.x(point₁) + GI.x(point₂)) * area_component + ycentroid += (GI.y(point₁) + GI.y(point₂)) * area_component + # Advance the point buffer by 1 point + point₁ = point₂ end - return (centroid ./ total_area, total_area) + area /= 2 + xcentroid /= 6area + ycentroid /= 6area + return (xcentroid, ycentroid), abs(area) end -function centroid(poly::GB.Polygon{2, T}) where T - exterior_centroid, exterior_area = centroid_and_signed_area(poly.exterior) - - total_area = exterior_area - interior_numerator = Point{2, T}(0) - for interior in poly.interiors - interior_centroid, interior_area = centroid_and_signed_area(interior) - total_area += interior_area - interior_numerator += interior_centroid * interior_area +""" + centroid_and_area(::GI.PolygonTrait, geom)::(::Tuple{T, T}, ::Real) + +Returns the centroid and area of a given polygon. +""" +function centroid_and_area(::GI.PolygonTrait, geom) + # Exterior ring's centroid and area + (xcentroid, ycentroid), area = centroid_and_area(GI.getexterior(geom)) + # Weight exterior centroid by area + xcentroid *= area + ycentroid *= area + # Loop over any holes within the polygon + for hole in GI.gethole(geom) + # Hole polygon's centroid and area + (xinterior, yinterior), interior_area = centroid_and_area(hole) + # Accumulate the area component into `area` + area -= interior_area + # Weighted average of centroid components + xcentroid -= xinterior * interior_area + ycentroid -= yinterior * interior_area end - - return (exterior_centroid * exterior_area - interior_numerator) / total_area - -end - -function centroid(multipoly::GB.MultiPolygon) - centroids = centroid.(multipoly.polygons) - areas = signed_area.(multipoly.polygons) - areas ./= sum(areas) - - return sum(centroids .* areas) / sum(areas) + xcentroid /= area + ycentroid /= area + return (xcentroid, ycentroid), area end - -function centroid(rect::GB.Rect{N, T}) where {N, T} - return Point{N, T}(rect.origin .- rect.widths ./ 2) -end - -function centroid(sphere::GB.HyperSphere{N, T}) where {N, T} - return sphere.center -end +""" + centroid_and_area(::GI.MultiPolygonTrait, geom)::(::Tuple{T, T}, ::Real) + +Returns the centroid and area of a given multipolygon. +""" +function centroid_and_area(::GI.MultiPolygonTrait, geom) + # First polygon's centroid and area + (xcentroid, ycentroid), area = centroid_and_area(GI.getpolygon(geom, 1)) + # Weight first polygon's centroid by area + xcentroid *= area + ycentroid *= area + # Loop over any polygons within the multipolygon + for i in 2:GI.ngeom(geom) #poly in GI.getpolygon(geom) + # Polygon centroid and area + (xpoly, ypoly), poly_area = centroid_and_area(GI.getpolygon(geom, i)) + # Accumulate the area component into `area` + area += poly_area + # Weighted average of centroid components + xcentroid += xpoly * poly_area + ycentroid += ypoly * poly_area + end + xcentroid /= area + ycentroid /= area + return (xcentroid, ycentroid), area +end \ No newline at end of file diff --git a/src/methods/signed_area.jl b/src/methods/signed_area.jl index ecd13ae61..c485d66c4 100644 --- a/src/methods/signed_area.jl +++ b/src/methods/signed_area.jl @@ -25,7 +25,7 @@ export signed_area # f # ``` # The points are ordered in a clockwise fashion, which means that the signed area -# is positive. If we reverse the order of the points, we get a negative area. +# is negative. If we reverse the order of the points, we get a postive area. # ## Implementation diff --git a/src/utils.jl b/src/utils.jl index 01187ee36..dc6c078eb 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -94,11 +94,6 @@ function to_extent(edges::Vector{Edge}) Extents.Extent(X=x, Y=y) end -function to_extent(edges::Vector{Edge}) - x, y = extrema(first, edges) - Extents.Extent(X=x, Y=y) -end - function to_points(xs) points = Vector{TuplePoint}(undef, _npoint(x)) _to_points!(points, x, 1) diff --git a/test/data/polygon_generation.jl b/test/data/polygon_generation.jl new file mode 100644 index 000000000..e0ab68e9f --- /dev/null +++ b/test/data/polygon_generation.jl @@ -0,0 +1,66 @@ +""" + generate_random_poly( + x, + y, + nverts, + avg_radius, + irregularity, + spikiness, + ) +Generate a random polygon given a center point, number of vertices, and measures +of the polygon's irregularity. +Inputs: + x x-coordinate for center point + y y-coordinate for center point + nverts number of vertices + avg_radius average radius for each point to center point + irregularity measure of randomness for difference in angle between + points (between 0 and 1) + spikiness measure of randomness for difference in radius + between points (between 0 and 1) + rng random number generator for polygon creation +Output: + Vector{Vector{Vector{T}}} representing polygon coordinates +Note: + Check your outputs! No guarentee that the polygon's aren't self-intersecting +""" +function generate_random_poly( + x, + y, + nverts, + avg_radius, + irregularity::T, + spikiness::T, + rng = Xoshiro() +) where T <: Real + # Check argument validity + @assert 0 <= irregularity <= 1 "Irregularity must be between 0 and 1" + @assert 0 <= spikiness <= 1 "Spikiness must be between 0 and 1" + # Setup basic parameters + avg_angle = 2π / nverts + ϵ_angle = irregularity * avg_angle + # ϵ_rad = spikiness * avg_radius + smallest_angle_step = avg_angle - ϵ_angle + # smallest_rad = avg_radius - ϵ_rad + current_angle = rand(rng) * 2π + rad_distribution = Distributions.Normal(avg_radius, spikiness) + points = [zeros(T, 2) for _ in 1:nverts] + # Generate angle steps around polygon + angle_steps = zeros(T, nverts) + cumsum = T(0) + for i in 1:nverts + step = smallest_angle_step + 2ϵ_angle * rand(rng) + angle_steps[i] = step + cumsum += step + end + angle_steps ./= (cumsum / 2π) + # Generate polygon points at given angles and radii + for i in 1:nverts + rad = clamp(rand(rad_distribution), 0, 2avg_radius) #smallest_rad + 2ϵ_rad * rand(rng) + points[i][1] = x + rad * cos(current_angle) + points[i][2] = y + rad * sin(current_angle) + current_angle -= angle_steps[i] + end + points[end] .= points[1] + return [points] +end diff --git a/test/methods/centroid.jl b/test/methods/centroid.jl new file mode 100644 index 000000000..9aad3d861 --- /dev/null +++ b/test/methods/centroid.jl @@ -0,0 +1,110 @@ +@testset "Lines/Rings" begin + # Basic line string + l1 = LG.LineString([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]) + c1, len1 = GO.centroid_and_length(l1) + c1_from_LG = LG.centroid(l1) + @test c1[1] ≈ GI.x(c1_from_LG) + @test c1[2] ≈ GI.y(c1_from_LG) + @test len1 ≈ 20.0 + + # Spiral line string + l2 = LG.LineString([[0.0, 0.0], [2.5, -2.5], [-5.0, -3.0], [-4.0, 6.0], [10.0, 10.0], [12.0, -14.56]]) + c2, len2 = GO.centroid_and_length(l2) + c2_from_LG = LG.centroid(l2) + @test c2[1] ≈ GI.x(c2_from_LG) + @test c2[2] ≈ GI.y(c2_from_LG) + @test len2 ≈ 59.3090856788928 + + # Test that non-closed line strings throw an error for centroid_and_area + @test_throws AssertionError GO.centroid_and_area(l2) + + # Basic linear ring - note that this still uses weighting by length + r1 = LG.LinearRing([[0.0, 0.0], [3456.0, 7894.0], [6291.0, 1954.0], [0.0, 0.0]]) + c3 = GO.centroid(r1) + c3_from_LG = LG.centroid(r1) + @test c3[1] ≈ GI.x(c3_from_LG) + @test c3[2] ≈ GI.y(c3_from_LG) +end + +@testset "Polygons" begin + # Basic rectangle + p1 = AG.fromWKT("POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))") + c1 = GO.centroid(p1) + c1 .≈ (5, 5) + @test GI.x(c1) ≈ 5 + @test GI.y(c1) ≈ 5 + + # Concave c-shaped polygon + p2 = LG.Polygon([[ + [11.0, 0.0], [11.0, 3.0], [14.0, 3.0], [14.0, 2.0], [12.0, 2.0], + [12.0, 1.0], [14.0, 1.0], [14.0, 0.0], [11.0, 0.0], + ]]) + c2, area2 = GO.centroid_and_area(p2) + c2_from_LG = LG.centroid(p2) + @test c2[1] ≈ GI.x(c2_from_LG) + @test c2[2] ≈ GI.y(c2_from_LG) + @test area2 ≈ LG.area(p2) + + # Randomly generated polygon with lots of sides + p3 = LG.Polygon([[ + [14.567, 8.974], [13.579, 8.849], [12.076, 8.769], [11.725, 8.567], + [11.424, 6.451], [10.187, 7.712], [8.187, 6.795], [8.065, 8.096], + [6.827, 8.287], [7.1628, 8.9221], [5.8428, 10.468], [7.987, 11.734], + [7.989, 12.081], [8.787, 11.930], [7.568, 13.926], [9.330, 13.340], + [9.6817, 13.913], [10.391, 12.222], [12.150, 12.032], [14.567, 8.974], + ]]) + c3, area3 = GO.centroid_and_area(p3) + c3_from_LG = LG.centroid(p3) + @test c3[1] ≈ GI.x(c3_from_LG) + @test c3[2] ≈ GI.y(c3_from_LG) + @test area3 ≈ LG.area(p3) + + # Polygon with one hole + p4 = LG.Polygon([ + [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]], + [[2.3, 2.7], [2.5, 4.9], [4.1, 5.2], [4.2, 1.9], [2.3, 2.7]], + ]) + c4, area4 = GO.centroid_and_area(p4) + c4_from_LG = LG.centroid(p4) + @test c4[1] ≈ GI.x(c4_from_LG) + @test c4[2] ≈ GI.y(c4_from_LG) + @test area4 ≈ LG.area(p4) + + # Polygon with two holes + p5 = LG.Polygon([ + [[-10.0, -10.0], [-2.0, 0.0], [6.0, -10.0], [-10.0, -10.0]], + [[-8.0, -8.0], [-8.0, -7.0], [-4.0, -7.0], [-4.0, -8.0], [-8.0, -8.0]], + [[-3.0,-9.0], [3.0, -9.0], [3.0, -8.5], [-3.0, -8.5], [-3.0, -9.0]], + ]) + c5 = GO.centroid(p5) + c5_from_LG = LG.centroid(p5) + @test c5[1] ≈ GI.x(c5_from_LG) + @test c5[2] ≈ GI.y(c5_from_LG) + + # Same polygon as P5 but using a GeoInterface polygon + p6 = GI.Polygon([ + [(-10.0, -10.0), (-2.0, 0.0), (6.0, -10.0), (-10.0, -10.0)], + [(-8.0, -8.0), (-8.0, -7.0), (-4.0, -7.0), (-4.0, -8.0), (-8.0, -8.0)], + [(-3.0, -9.0), (3.0, -9.0), (3.0, -8.5), (-3.0, -8.5), (-3.0, -9.0)], + ]) + c6 = GO.centroid(p6) + @test all(c5 .≈ c6) +end +@testset "MultiPolygons" begin + # Combine poylgons made above + m1 = LG.MultiPolygon([ + [ + [[11.0, 0.0], [11.0, 3.0], [14.0, 3.0], [14.0, 2.0], [12.0, 2.0], + [12.0, 1.0], [14.0, 1.0], [14.0, 0.0], [11.0, 0.0]], + ], + [ + [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]], + [[2.3, 2.7], [2.5, 4.9], [4.1, 5.2], [4.2, 1.9], [2.3, 2.7]], + ] + ]) + c1, area1 = GO.centroid_and_area(m1) + c1_from_LG = LG.centroid(m1) + @test c1[1] ≈ GI.x(c1_from_LG) + @test c1[2] ≈ GI.y(c1_from_LG) + @test area1 ≈ LG.area(m1) +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index a270b6f31..c4fc39f3e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,15 +4,22 @@ using Test using GeometryOps.GeoInterface using GeometryOps.GeometryBasics using ArchGDAL +using LibGEOS +using Random, Distributions const GI = GeoInterface const AG = ArchGDAL +const LG = LibGEOS +const GO = GeometryOps @testset "GeometryOps.jl" begin @testset "Primitives" begin include("primitives.jl") end + # Methods + @testset "Barycentric coordinate operations" begin include("methods/barycentric.jl") end @testset "Bools" begin include("methods/bools.jl") end + @testset "Centroid" begin include("methods/centroid.jl") end @testset "Signed Area" begin include("methods/signed_area.jl") end - @testset "Barycentric coordinate operations" begin include("methods/barycentric.jl") end + # Transformations @testset "Reproject" begin include("transformations/reproject.jl") end @testset "Flip" begin include("transformations/flip.jl") end @testset "Simplify" begin include("transformations/simplify.jl") end