diff --git a/python/cuspatial/benchmarks/api/bench_api.py b/python/cuspatial/benchmarks/api/bench_api.py index b9e493e15..b7945a383 100644 --- a/python/cuspatial/benchmarks/api/bench_api.py +++ b/python/cuspatial/benchmarks/api/bench_api.py @@ -118,12 +118,8 @@ def bench_pairwise_linestring_distance(benchmark, gpu_dataframe): geometry = gpu_dataframe["geometry"] benchmark( cuspatial.pairwise_linestring_distance, - geometry.polygons.ring_offset, - geometry.polygons.x, - geometry.polygons.y, - geometry.polygons.ring_offset, - geometry.polygons.x, - geometry.polygons.y, + geometry, + geometry, ) @@ -165,8 +161,8 @@ def bench_quadtree_on_points(benchmark, gpu_dataframe): def bench_quadtree_point_in_polygon(benchmark, polygons): polygons = polygons["geometry"].polygons - x_points = (cupy.random.random(10000000) - 0.5) * 360 - y_points = (cupy.random.random(10000000) - 0.5) * 180 + x_points = (cupy.random.random(50000000) - 0.5) * 360 + y_points = (cupy.random.random(50000000) - 0.5) * 180 scale = 5 max_depth = 7 min_size = 125 @@ -263,15 +259,16 @@ def bench_quadtree_point_to_nearest_linestring(benchmark): def bench_point_in_polygon(benchmark, gpu_dataframe): - x_points = (cupy.random.random(10000000) - 0.5) * 360 - y_points = (cupy.random.random(10000000) - 0.5) * 180 + x_points = (cupy.random.random(50000000) - 0.5) * 360 + y_points = (cupy.random.random(50000000) - 0.5) * 180 short_dataframe = gpu_dataframe.iloc[0:32] geometry = short_dataframe["geometry"] + polygon_offset = cudf.Series(geometry.polygons.geometry_offset[0:31]) benchmark( cuspatial.point_in_polygon, x_points, y_points, - geometry.polygons.geometry_offset[0:31], + polygon_offset, geometry.polygons.ring_offset, geometry.polygons.x, geometry.polygons.y, diff --git a/python/cuspatial/cuspatial/_lib/CMakeLists.txt b/python/cuspatial/cuspatial/_lib/CMakeLists.txt index ff76b297e..375aa3f12 100644 --- a/python/cuspatial/cuspatial/_lib/CMakeLists.txt +++ b/python/cuspatial/cuspatial/_lib/CMakeLists.txt @@ -18,6 +18,7 @@ set(cython_sources interpolate.pyx nearest_points.pyx point_in_polygon.pyx + pairwise_point_in_polygon.pyx polygon_bounding_boxes.pyx linestring_bounding_boxes.pyx quadtree.pyx diff --git a/python/cuspatial/cuspatial/_lib/cpp/pairwise_point_in_polygon.pxd b/python/cuspatial/cuspatial/_lib/cpp/pairwise_point_in_polygon.pxd new file mode 100644 index 000000000..90149e590 --- /dev/null +++ b/python/cuspatial/cuspatial/_lib/cpp/pairwise_point_in_polygon.pxd @@ -0,0 +1,17 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. + +from libcpp.memory cimport unique_ptr + +from cudf._lib.column cimport column, column_view + + +cdef extern from "cuspatial/pairwise_point_in_polygon.hpp" \ + namespace "cuspatial" nogil: + cdef unique_ptr[column] pairwise_point_in_polygon( + const column_view & test_points_x, + const column_view & test_points_y, + const column_view & poly_offsets, + const column_view & poly_ring_offsets, + const column_view & poly_points_x, + const column_view & poly_points_y + ) except + diff --git a/python/cuspatial/cuspatial/_lib/pairwise_point_in_polygon.pyx b/python/cuspatial/cuspatial/_lib/pairwise_point_in_polygon.pyx new file mode 100644 index 000000000..8ce2c952e --- /dev/null +++ b/python/cuspatial/cuspatial/_lib/pairwise_point_in_polygon.pyx @@ -0,0 +1,42 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. + +from libcpp.memory cimport unique_ptr +from libcpp.utility cimport move + +from cudf._lib.column cimport Column, column, column_view + +from cuspatial._lib.cpp.pairwise_point_in_polygon cimport ( + pairwise_point_in_polygon as cpp_pairwise_point_in_polygon, +) + + +def pairwise_point_in_polygon( + Column test_points_x, + Column test_points_y, + Column poly_offsets, + Column poly_ring_offsets, + Column poly_points_x, + Column poly_points_y +): + cdef column_view c_test_points_x = test_points_x.view() + cdef column_view c_test_points_y = test_points_y.view() + cdef column_view c_poly_offsets = poly_offsets.view() + cdef column_view c_poly_ring_offsets = poly_ring_offsets.view() + cdef column_view c_poly_points_x = poly_points_x.view() + cdef column_view c_poly_points_y = poly_points_y.view() + + cdef unique_ptr[column] result + + with nogil: + result = move( + cpp_pairwise_point_in_polygon( + c_test_points_x, + c_test_points_y, + c_poly_offsets, + c_poly_ring_offsets, + c_poly_points_x, + c_poly_points_y + ) + ) + + return Column.from_unique_ptr(move(result)) diff --git a/python/cuspatial/cuspatial/core/binops/__init__.py b/python/cuspatial/cuspatial/core/binops/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/cuspatial/cuspatial/core/binops/contains.py b/python/cuspatial/cuspatial/core/binops/contains.py new file mode 100644 index 000000000..7fab07f76 --- /dev/null +++ b/python/cuspatial/cuspatial/core/binops/contains.py @@ -0,0 +1,89 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. + +from cudf import Series +from cudf.core.column import as_column + +from cuspatial._lib.pairwise_point_in_polygon import ( + pairwise_point_in_polygon as cpp_pairwise_point_in_polygon, +) +from cuspatial._lib.point_in_polygon import ( + point_in_polygon as cpp_point_in_polygon, +) +from cuspatial.utils.column_utils import normalize_point_columns + + +def contains_properly( + test_points_x, + test_points_y, + poly_offsets, + poly_ring_offsets, + poly_points_x, + poly_points_y, +): + """Compute from a series of points and a series of polygons which points + are properly contained within the corresponding polygon. Polygon A contains + Point B properly if B intersects the interior of A but not the boundary (or + exterior). + + Note that polygons must be closed: the first and last vertex of each + polygon must be the same. + + Parameters + ---------- + test_points_x + x-coordinate of points to test for containment + test_points_y + y-coordinate of points to test for containment + poly_offsets + beginning index of the first ring in each polygon + poly_ring_offsets + beginning index of the first point in each ring + poly_points_x + x-coordinates of polygon vertices + poly_points_y + y-coordinates of polygon vertices + + Returns + ------- + result : cudf.Series + A Series of boolean values indicating whether each point falls + within its corresponding polygon. + """ + + if len(poly_offsets) == 0: + return Series() + ( + test_points_x, + test_points_y, + poly_points_x, + poly_points_y, + ) = normalize_point_columns( + as_column(test_points_x), + as_column(test_points_y), + as_column(poly_points_x), + as_column(poly_points_y), + ) + poly_offsets_column = as_column(poly_offsets, dtype="int32") + poly_ring_offsets_column = as_column(poly_ring_offsets, dtype="int32") + + if len(test_points_x) == len(poly_offsets): + pip_result = cpp_pairwise_point_in_polygon( + test_points_x, + test_points_y, + poly_offsets_column, + poly_ring_offsets_column, + poly_points_x, + poly_points_y, + ) + else: + pip_result = cpp_point_in_polygon( + test_points_x, + test_points_y, + poly_offsets_column, + poly_ring_offsets_column, + poly_points_x, + poly_points_y, + ) + + result = Series(pip_result, dtype="bool") + return result diff --git a/python/cuspatial/cuspatial/core/geoseries.py b/python/cuspatial/cuspatial/core/geoseries.py index 6e2ccd200..dc219887d 100644 --- a/python/cuspatial/cuspatial/core/geoseries.py +++ b/python/cuspatial/cuspatial/core/geoseries.py @@ -25,6 +25,12 @@ import cuspatial.io.pygeoarrow as pygeoarrow from cuspatial.core._column.geocolumn import GeoColumn from cuspatial.core._column.geometa import Feature_Enum, GeoMeta +from cuspatial.core.binops.contains import contains_properly +from cuspatial.utils.column_utils import ( + contains_only_linestrings, + contains_only_multipoints, + contains_only_polygons, +) T = TypeVar("T", bound="GeoSeries") @@ -159,6 +165,13 @@ def _get_current_features(self, type): existing_features = self._col.take(existing_indices._column) return existing_features + def point_indices(self): + # Return a cupy.ndarray containing the index values that each + # point belongs to. + offsets = cp.arange(0, len(self.xy) + 1, 2) + sizes = offsets[1:] - offsets[:-1] + return cp.repeat(self._series.index, sizes) + class MultiPointGeoColumnAccessor(GeoColumnAccessor): def __init__(self, list_series, meta): super().__init__(list_series, meta) @@ -168,6 +181,13 @@ def __init__(self, list_series, meta): def geometry_offset(self): return self._get_current_features(self._type).offsets.values + def point_indices(self): + # Return a cupy.ndarray containing the index values from the + # MultiPoint GeoSeries that each individual point is member of. + offsets = cp.array(self.geometry_offset) + sizes = offsets[1:] - offsets[:-1] + return cp.repeat(self._series.index, sizes) + class LineStringGeoColumnAccessor(GeoColumnAccessor): def __init__(self, list_series, meta): super().__init__(list_series, meta) @@ -183,6 +203,13 @@ def part_offset(self): self._type ).elements.offsets.values + def point_indices(self): + # Return a cupy.ndarray containing the index values from the + # LineString GeoSeries that each individual point is member of. + offsets = cp.array(self.part_offset) + sizes = offsets[1:] - offsets[:-1] + return cp.repeat(self._series.index, sizes) + class PolygonGeoColumnAccessor(GeoColumnAccessor): def __init__(self, list_series, meta): super().__init__(list_series, meta) @@ -204,6 +231,13 @@ def ring_offset(self): self._type ).elements.elements.offsets.values + def point_indices(self): + # Return a cupy.ndarray containing the index values from the + # Polygon GeoSeries that each individual point is member of. + offsets = cp.array(self.ring_offset) + sizes = offsets[1:] - offsets[:-1] + return cp.repeat(self._series.index, sizes) + @property def points(self): """ @@ -634,7 +668,9 @@ def align(self, other): dtype: geometry) """ - index = other.index + index = ( + other.index if len(other.index) >= len(self.index) else self.index + ) aligned_left = self._align_to_index(index) aligned_right = other._align_to_index(index) aligned_right.index = index @@ -654,3 +690,141 @@ def _gather( self, gather_map, keep_index=True, nullify=False, check_bounds=True ): return self.iloc[gather_map] + + def contains_properly(self, other, align=True): + """Compute from a GeoSeries of points and a GeoSeries of polygons which + points are properly contained within the corresponding polygon. Polygon + A contains Point B properly if B intersects the interior of A but not + the boundary (or exterior). + + Parameters + ---------- + other + a cuspatial.GeoSeries + align=True + to align the indices before computing .contains or not. If the + indices are not aligned, they will be compared based on their + implicit row order. + + Examples + -------- + + Test if a polygon is inside another polygon: + >>> point = cuspatial.GeoSeries( + [Point(0.5, 0.5)], + ) + >>> polygon = cuspatial.GeoSeries( + [ + Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), + ] + ) + >>> print(polygon.contains(point)) + 0 False + dtype: bool + + + Test whether three points fall within either of two polygons + >>> point = cuspatial.GeoSeries( + [Point(0, 0)], + [Point(-1, 0)], + [Point(-2, 0)], + [Point(0, 0)], + [Point(-1, 0)], + [Point(-2, 0)], + ) + >>> polygon = cuspatial.GeoSeries( + [ + Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), + Polygon([[-2, -2], [-2, 2], [2, 2], [-2, -2]]), + Polygon([[-2, -2], [-2, 2], [2, 2], [-2, -2]]), + Polygon([[-2, -2], [-2, 2], [2, 2], [-2, -2]]), + ] + ) + >>> print(polygon.contains(point)) + 0 False + 1 False + 2 False + 3 False + 4 False + 5 True + dtype: bool + + Note + ---- + poly_ring_offsets must contain only the rings that make up the polygons + indexed by poly_offsets. If there are rings in poly_ring_offsets that + are not part of the polygons in poly_offsets, results are likely to be + incorrect and behavior is undefined. + Note + ---- + Polygons must be closed: the first and last coordinate of each polygon + must be the same. + + Returns + ------- + result : cudf.Series + A Series of boolean values indicating whether each point falls + within the corresponding polygon in the input. + """ + if not contains_only_polygons(self): + raise TypeError( + "`.contains` can only be called with polygon series." + ) + + (lhs, rhs) = self.align(other) if align else (self, other) + + # RHS conditioning: + mode = "POINTS" + # point in polygon + if contains_only_linestrings(rhs): + # condition for linestrings + mode = "LINESTRINGS" + geom = rhs.lines + elif contains_only_polygons(rhs) is True: + # polygon in polygon + mode = "POLYGONS" + geom = rhs.polygons + elif contains_only_multipoints(rhs) is True: + # mpoint in polygon + mode = "MULTIPOINTS" + geom = rhs.multipoints + else: + # no conditioning is required + geom = rhs.points + xy_points = geom.xy + point_indices = geom.point_indices() + points = GeoSeries(GeoColumn._from_points_xy(xy_points._column)).points + + # call pip on the three subtypes on the right: + point_result = contains_properly( + points.x, + points.y, + lhs.polygons.part_offset[:-1], + lhs.polygons.ring_offset[:-1], + lhs.polygons.x, + lhs.polygons.y, + ) + if ( + mode == "LINESTRINGS" + or mode == "POLYGONS" + or mode == "MULTIPOINTS" + ): + # process for completed linestrings, polygons, and multipoints. + # Not necessary for points. + result = cudf.DataFrame( + {"idx": point_indices, "pip": point_result} + ) + # if the number of points in the polygon is equal to the number of + # points, then the requirements for `.contains_properly` are met + # for this geometry type. + df_result = ( + result.groupby("idx").sum().sort_index() + == result.groupby("idx").count().sort_index() + ) + point_result = cudf.Series( + df_result["pip"], index=cudf.RangeIndex(0, len(df_result)) + ) + point_result.name = None + return point_result diff --git a/python/cuspatial/cuspatial/tests/binops/test_contains_properly.py b/python/cuspatial/cuspatial/tests/binops/test_contains_properly.py new file mode 100644 index 000000000..1128a04ca --- /dev/null +++ b/python/cuspatial/cuspatial/tests/binops/test_contains_properly.py @@ -0,0 +1,351 @@ +import geopandas as gpd +import numpy as np +import pytest +from shapely.geometry import LineString, Point, Polygon + +import cuspatial + + +def test_manual_polygons(): + gpdlhs = gpd.GeoSeries([Polygon(((-8, -8), (-8, 8), (8, 8), (8, -8)))] * 6) + gpdrhs = gpd.GeoSeries( + [ + Polygon(((-8, -8), (-8, 8), (8, 8), (8, -8))), + Polygon(((-2, -2), (-2, 2), (2, 2), (2, -2))), + Polygon(((-10, -2), (-10, 2), (-6, 2), (-6, -2))), + Polygon(((-2, 8), (-2, 12), (2, 12), (2, 8))), + Polygon(((6, 0), (8, 2), (10, 0), (8, -2))), + Polygon(((-2, -8), (-2, -4), (2, -4), (2, -8))), + ] + ) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains_properly(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (got == np.array([False, True, False, False, False, False])).all() + assert ( + expected == np.array([True, True, False, False, False, True]) + ).all() + got = rhs.contains_properly(lhs).values_host + expected = gpdrhs.contains(gpdlhs).values + assert (got == np.array([False, False, False, False, False, False])).all() + assert ( + expected == np.array([True, False, False, False, False, False]) + ).all() + + +def test_one_polygon_one_linestring_crosses_the_diagonal(linestring_generator): + gpdlinestring = gpd.GeoSeries(LineString([[0, 0], [1, 1]])) + gpdpolygon = gpd.GeoSeries( + Polygon([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]) + ) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygon) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygon.contains(gpdlinestring).values + assert not np.any(got) + assert np.all(expected) + + +def test_one_polygon_with_hole_one_linestring_crossing_it( + linestring_generator, +): + gpdlinestring = gpd.GeoSeries(LineString([[0.5, 2.0], [3.5, 2.0]])) + gpdpolygon = gpd.GeoSeries( + Polygon( + ( + [0, 0], + [0, 4], + [4, 4], + [4, 0], + [0, 0], + ), + [ + ( + [1, 1], + [1, 3], + [3, 3], + [3, 1], + [1, 1], + ) + ], + ) + ) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygon) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygon.contains(gpdlinestring).values + assert np.all(got) + assert not np.any(expected) + + +@pytest.mark.parametrize( + "point, polygon, expects", + [ + [ + Point([0.6, 0.06]), + Polygon([[0, 0], [10, 1], [1, 1], [0, 0]]), + False, + ], + [ + Point([3.333, 1.111]), + Polygon([[6, 2], [3, 1], [3, 4], [6, 2]]), + True, + ], + [Point([3.33, 1.11]), Polygon([[6, 2], [3, 1], [3, 4], [6, 2]]), True], + ], +) +def test_float_precision_limits_failures(point, polygon, expects): + gpdpoint = gpd.GeoSeries(point) + gpdpolygon = gpd.GeoSeries(polygon) + point = cuspatial.from_geopandas(gpdpoint) + polygon = cuspatial.from_geopandas(gpdpolygon) + got = polygon.contains_properly(point).values_host + # GeoPandas results here are inconsistent. + # expected = gpdpolygon.contains(gpdpoint).values + # assert expected == True or False + assert not np.any(got) + + +@pytest.mark.parametrize( + "point, polygon, expects", + [ + [ + Point([0.66, 0.006]), + Polygon([[0, 0], [10, 1], [1, 1], [0, 0]]), + False, + ], + [ + Point([0.666, 0.0006]), + Polygon([[0, 0], [10, 1], [1, 1], [0, 0]]), + False, + ], + [Point([3.3, 1.1]), Polygon([[6, 2], [3, 1], [3, 4], [6, 2]]), True], + ], +) +def test_float_precision_limits(point, polygon, expects): + """Corner case to test point on edges with floating point precision + limits. + Unique success cases identified by @mharris. These go in a pair + with test_float_precision_limits_failures because these are + inconsistent results, where 0.6 fails above (as True, within the + polygon) and 0.66 below succeeds, though they are colinear. + """ + gpdpoint = gpd.GeoSeries(point) + gpdpolygon = gpd.GeoSeries(polygon) + point = cuspatial.from_geopandas(gpdpoint) + polygon = cuspatial.from_geopandas(gpdpolygon) + got = polygon.contains_properly(point).values_host + expected = gpdpolygon.contains(gpdpoint).values + assert got == expected + assert got[0] == expects + + +clockwiseTriangle = Polygon([[0, 0], [0, 1], [1, 1], [0, 0]]) +clockwiseSquare = Polygon( + [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5], [-0.5, -0.5]] +) + + +@pytest.mark.parametrize( + "point, polygon, expects", + [ + [Point([-0.5, -0.5]), clockwiseSquare, False], + [Point([-0.5, 0.5]), clockwiseSquare, False], + [Point([0.5, 0.5]), clockwiseSquare, False], + [Point([0.5, -0.5]), clockwiseSquare, False], + # clockwise square, should be true + [Point([-0.5, 0.0]), clockwiseSquare, False], + [Point([0.0, 0.5]), clockwiseSquare, False], + [Point([0.5, 0.0]), clockwiseSquare, False], + [Point([0.0, -0.5]), clockwiseSquare, False], + # wound clockwise, should be false + [Point([0, 0]), clockwiseTriangle, False], + [Point([0.0, 1.0]), clockwiseTriangle, False], + [Point([1.0, 1.0]), clockwiseTriangle, False], + [Point([0.0, 0.5]), clockwiseTriangle, False], + [Point([0.5, 0.5]), clockwiseTriangle, False], + [Point([0.5, 1]), clockwiseTriangle, False], + # wound clockwise, should be true + [Point([0.25, 0.5]), clockwiseTriangle, True], + [Point([0.75, 0.9]), clockwiseTriangle, True], + # wound counter clockwise, should be false + [Point([0.0, 0.0]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), False], + [Point([1.0, 0.0]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), False], + [Point([1.0, 1.0]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), False], + [Point([0.5, 0.0]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), False], + [Point([0.5, 0.5]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), False], + # wound counter clockwise, should be true + [Point([0.5, 0.25]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), True], + [Point([0.9, 0.75]), Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), True], + ], +) +def test_point_in_polygon(point, polygon, expects): + gpdpoint = gpd.GeoSeries(point) + gpdpolygon = gpd.GeoSeries(polygon) + point = cuspatial.from_geopandas(gpdpoint) + polygon = cuspatial.from_geopandas(gpdpolygon) + got = polygon.contains_properly(point).values_host + expected = gpdpolygon.contains(gpdpoint).values + assert got == expected + assert got[0] == expects + + +def test_two_points_one_polygon(): + gpdpoint = gpd.GeoSeries([Point(0, 0), Point(0, 0)]) + gpdpolygon = gpd.GeoSeries(Polygon([[0, 0], [1, 0], [1, 1], [0, 0]])) + point = cuspatial.from_geopandas(gpdpoint) + polygon = cuspatial.from_geopandas(gpdpolygon) + got = polygon.contains_properly(point).values_host + expected = gpdpolygon.contains(gpdpoint).values + assert (got == expected).all() + + +def test_one_point_two_polygons(): + gpdpoint = gpd.GeoSeries([Point(0, 0)]) + gpdpolygon = gpd.GeoSeries( + [ + Polygon([[0, 0], [1, 0], [1, 1], [0, 0]]), + Polygon([[-2, -2], [-2, 2], [2, 2], [-2, -2]]), + ] + ) + point = cuspatial.from_geopandas(gpdpoint) + polygon = cuspatial.from_geopandas(gpdpolygon) + got = polygon.contains_properly(point).values_host + expected = gpdpolygon.contains(gpdpoint).values + assert (got == expected).all() + + +def test_ten_pair_points(point_generator, polygon_generator): + gpdpoints = gpd.GeoSeries([*point_generator(10)]) + gpdpolygons = gpd.GeoSeries([*polygon_generator(10, 0)]) + points = cuspatial.from_geopandas(gpdpoints) + polygons = cuspatial.from_geopandas(gpdpolygons) + got = polygons.contains_properly(points).values_host + expected = gpdpolygons.contains(gpdpoints).values + assert (got == expected).all() + + +def test_one_polygon_with_hole_one_linestring_inside_it(linestring_generator): + gpdlinestring = gpd.GeoSeries(LineString([[1.5, 2.0], [2.5, 2.0]])) + gpdpolygon = gpd.GeoSeries( + Polygon( + ( + [0, 0], + [0, 4], + [4, 4], + [4, 0], + [0, 0], + ), + [ + ( + [1, 1], + [1, 3], + [3, 3], + [3, 1], + [1, 1], + ) + ], + ) + ) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygon) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygon.contains(gpdlinestring).values + assert (got == expected).all() + + +def test_one_polygon_one_linestring(linestring_generator): + gpdlinestring = gpd.GeoSeries([*linestring_generator(1, 4)]) + gpdpolygon = gpd.GeoSeries( + Polygon([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]) + ) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygon) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygon.contains(gpdlinestring).values + assert (got == expected).all() + + +def test_six_polygons_six_linestrings(linestring_generator): + gpdlinestring = gpd.GeoSeries( + [ + LineString([[1.35, 0.35], [0.35, 0.65]]), + LineString([[0.35, 0.35], [0.35, 0.65]]), + LineString([[0.25, 0.25], [0.25, 0.75]]), + LineString([[0.15, 0.15], [0.15, 0.85]]), + LineString([[0.05, 0.05], [0.05, 0.95]]), + LineString([[0.05, 0.05], [1.05, 0.95]]), + ] + ) + gpdpolygon = gpd.GeoSeries( + [ + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]), + ] + ) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygon) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygon.contains(gpdlinestring).values + assert (got == expected).all() + + +def test_max_polygons_max_linestrings(linestring_generator, polygon_generator): + gpdlinestring = gpd.GeoSeries([*linestring_generator(31, 3)]) + gpdpolygons = gpd.GeoSeries([*polygon_generator(31, 0)]) + linestring = cuspatial.from_geopandas(gpdlinestring) + polygons = cuspatial.from_geopandas(gpdpolygons) + got = polygons.contains_properly(linestring).values_host + expected = gpdpolygons.contains(gpdlinestring).values + assert (got == expected).all() + + +def test_one_polygon_one_polygon(polygon_generator): + gpdlhs = gpd.GeoSeries(Polygon([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]])) + gpdrhs = gpd.GeoSeries([*polygon_generator(1, 0)]) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains_properly(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (expected == got).all() + got = rhs.contains_properly(lhs).values_host + expected = gpdrhs.contains(gpdlhs).values + assert (got == expected).all() + + +def test_max_polygons_max_polygons(simple_polygon_generator): + gpdlhs = gpd.GeoSeries([*simple_polygon_generator(31, 1, 3)]) + gpdrhs = gpd.GeoSeries([*simple_polygon_generator(31, 1.49, 2)]) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains_properly(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (expected == got).all() + got = rhs.contains_properly(lhs).values_host + expected = gpdrhs.contains(gpdlhs).values + assert (got == expected).all() + + +def test_one_polygon_one_multipoint(multipoint_generator, polygon_generator): + gpdlhs = gpd.GeoSeries([*polygon_generator(1, 0)]) + gpdrhs = gpd.GeoSeries([*multipoint_generator(1, 5)]) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains_properly(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (got == expected).all() + + +def test_max_polygons_max_multipoints(multipoint_generator, polygon_generator): + gpdlhs = gpd.GeoSeries([*polygon_generator(31, 0, 1)]) + gpdrhs = gpd.GeoSeries([*multipoint_generator(31, 10)]) + rhs = cuspatial.from_geopandas(gpdrhs) + lhs = cuspatial.from_geopandas(gpdlhs) + got = lhs.contains_properly(rhs).values_host + expected = gpdlhs.contains(gpdrhs).values + assert (got == expected).all() diff --git a/python/cuspatial/cuspatial/tests/conftest.py b/python/cuspatial/cuspatial/tests/conftest.py index 3dfa3f676..571d29091 100644 --- a/python/cuspatial/cuspatial/tests/conftest.py +++ b/python/cuspatial/cuspatial/tests/conftest.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd import pytest +from shapely.affinity import rotate from shapely.geometry import ( LineString, MultiLineString, @@ -198,7 +199,6 @@ def generator(n, max_num_segments): @pytest.fixture def multilinestring_generator(linestring_generator): """Generator for n multilinestrings. - Usage: mls=generator(n, max_num_lines, max_num_segments) """ rstate = np.random.RandomState(0) @@ -213,6 +213,70 @@ def generator(n, max_num_geometries, max_num_segments): return generator +@pytest.fixture +def simple_polygon_generator(): + """Generator for polygons with no interior ring. + Usage: polygon_generator(n, distance_from_origin, radius) + """ + rstate = np.random.RandomState(0) + + def generator(n, distance_from_origin, radius=1.0): + for _ in range(n): + outer = Point(distance_from_origin * 2, 0).buffer(radius) + circle = Polygon(outer) + yield rotate(circle, rstate.random() * 2 * np.pi, use_radians=True) + + return generator + + +@pytest.fixture +def polygon_generator(): + """Generator for complex polygons. Each polygon will + have 1-4 randomly rotated interior rings. Each polygon + is a circle, with very small inner rings located in + a spiral around its center. + Usage: poly=generator(n, distance_from_origin, radius) + """ + rstate = np.random.RandomState(0) + + def generator(n, distance_from_origin, radius=1.0): + for _ in range(n): + outer = Point(distance_from_origin * 2, 0).buffer(radius) + inners = [] + for i in range(rstate.randint(1, 4)): + inner = Point(distance_from_origin + i * 0.1, 0).buffer( + 0.01 * radius + ) + inners.append(inner) + together = Polygon(outer, inners) + yield rotate( + together, rstate.random() * 2 * np.pi, use_radians=True + ) + + return generator + + +@pytest.fixture +def multipolygon_generator(): + """Generator for multi complex polygons. + Usage: multipolygon_generator(n, max_per_multi) + """ + rstate = np.random.RandomState(0) + + def generator(n, max_per_multi, distance_from_origin, radius): + for _ in range(n): + num_polygons = rstate.randint(1, max_per_multi) + yield MultiPolygon( + [ + *polygon_generator( + num_polygons, distance_from_origin, radius + ) + ] + ) + + return generator + + @pytest.fixture def slice_twenty(): return [ diff --git a/python/cuspatial/cuspatial/utils/column_utils.py b/python/cuspatial/cuspatial/utils/column_utils.py index 333e59bf5..fab7ff0e4 100644 --- a/python/cuspatial/cuspatial/utils/column_utils.py +++ b/python/cuspatial/cuspatial/utils/column_utils.py @@ -1,10 +1,12 @@ # Copyright (c) 2020, NVIDIA CORPORATION. +from typing import TypeVar + import numpy as np from cudf.api.types import is_datetime_dtype -from cuspatial.core.geoseries import GeoSeries +GeoSeries = TypeVar("GeoSeries", bound="GeoSeries") def normalize_point_columns(*cols): @@ -73,6 +75,14 @@ def contains_only_points(gs: GeoSeries): ) +def contains_only_multipoints(gs: GeoSeries): + """ + Returns true if `gs` contains only multipoints + """ + + return contain_single_type_geometry(gs) and (len(gs.multipoints.xy) > 0) + + def contains_only_linestrings(gs: GeoSeries): """ Returns true if `gs` contains only linestrings