This is an automated email from the ASF dual-hosted git repository.
jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git
The following commit(s) were added to refs/heads/master by this push:
new 7ec2e1e172 [GH-2183] Fix ST_Buffer logic cap_style when specifying
'side' parameter + Implement rest of geoseries.buffer() (#2184)
7ec2e1e172 is described below
commit 7ec2e1e172833a39c70ec1fcdfac43bbe9d3b8c4
Author: Peter Nguyen <[email protected]>
AuthorDate: Tue Jul 29 11:28:07 2025 -0700
[GH-2183] Fix ST_Buffer logic cap_style when specifying 'side' parameter +
Implement rest of geoseries.buffer() (#2184)
* Implement geoseries.buffer() properly with additional parameters
* Fix ST_Buffer 'side' handling logic
* Add java tests
* mvn spotless
* Update python/sedona/geopandas/geoseries.py
Co-authored-by: Copilot <[email protected]>
* Make docs more clear
---------
Co-authored-by: Jia Yu <[email protected]>
Co-authored-by: Copilot <[email protected]>
---
.../java/org/apache/sedona/common/Functions.java | 10 ++++--
.../org/apache/sedona/common/FunctionsTest.java | 13 ++++++++
docs/api/flink/Function.md | 2 +-
docs/api/snowflake/vector-data/Function.md | 2 +-
docs/api/sql/Function.md | 2 +-
python/sedona/geopandas/base.py | 37 ++++++++++++++++++++++
python/sedona/geopandas/geoseries.py | 23 +++++++++++++-
python/sedona/spark/sql/st_functions.py | 2 +-
python/tests/geopandas/test_geodataframe.py | 16 ++--------
python/tests/geopandas/test_geoseries.py | 20 ++++++++----
.../tests/geopandas/test_match_geopandas_series.py | 34 +++++++++++++++++---
11 files changed, 129 insertions(+), 32 deletions(-)
diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java
b/common/src/main/java/org/apache/sedona/common/Functions.java
index 16db38b9d7..0bab24344b 100644
--- a/common/src/main/java/org/apache/sedona/common/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/Functions.java
@@ -318,6 +318,7 @@ public class Functions {
BufferParameters bufferParameters = new BufferParameters();
String[] listParams = params.split(" ");
+ boolean endCapSpecified = false;
for (String param : listParams) {
String[] singleParam = param.split("=");
@@ -355,6 +356,7 @@ public class Functions {
"%s is not a valid option. Accepted options are %s.",
singleParam[1], Arrays.toString(endcapOptions)));
}
+ endCapSpecified = true;
}
// Set join style
else if (singleParam[0].equalsIgnoreCase(listBufferParameters[2])) {
@@ -387,12 +389,16 @@ public class Functions {
// Set side to add buffer
else if (singleParam[0].equalsIgnoreCase(listBufferParameters[5])) {
if (singleParam[1].equalsIgnoreCase(sideOptions[0])) {
- // It defaults to square end cap style when side is specified
- bufferParameters.setEndCapStyle(BufferParameters.CAP_SQUARE);
+ // Default value is 'both'
continue;
} else if (singleParam[1].equalsIgnoreCase(sideOptions[1])
|| singleParam[1].equalsIgnoreCase(sideOptions[2])) {
bufferParameters.setSingleSided(true);
+
+ // Specifying 'left' or 'right' defaults to square end cap style
when side is specified
+ if (!endCapSpecified) {
+ bufferParameters.setEndCapStyle(BufferParameters.CAP_SQUARE);
+ }
} else {
throw new IllegalArgumentException(
String.format(
diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
index 2b5eaed681..6de3d30669 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -2863,6 +2863,19 @@ public class FunctionsTest extends TestBase {
expected =
"POLYGON ((107.0711 82.9289, 100 80, 92.9289 82.9289, 90 90, 92.9289
97.0711, 100 100, 107.0711 97.0711, 110 90, 107.0711 82.9289))";
assertEquals(expected, actual);
+
+ // Using reducePrecision in the following tests modified the geometries
coordinates the
+ // coordinates causing the comparison to fail,
+ // so we just compare for exact match with postgis output
+ actual = Functions.asWKT(Functions.buffer(lineString, 10, false,
"side=right endcap=flat"));
+ expected =
+ "POLYGON ((0 0, 50 70, 70 -3, 60.35541713163109 -5.642351470786002,
45.912786454208465 47.0732505018066, 8.137334712067348 -5.812381937190963, 0
0))";
+ assertEquals(expected, actual);
+
+ actual = Functions.asWKT(Functions.buffer(lineString, 5, false, "side=both
endcap=square"));
+ expected =
+ "POLYGON ((45.93133264396633 72.90619096859548, 46.607775042289525
73.6732560265092, 47.42614312881237 74.2866374708669, 48.35219759000809
74.72067232686456, 49.34719367328685 74.9572012163925, 50.36950221028991
74.9863281196278, 51.37635131988457 74.80683440990555, 52.32561592057597
74.4262298392609, 53.17758018177616 73.86043834148188, 53.896599175569165
73.13313179820986, 54.452590207913495 72.27473964233116, 54.82229143418446
71.321175735393, 74.82229143418445 -1.678824264606 [...]
+ assertEquals(expected, actual);
}
@Test
diff --git a/docs/api/flink/Function.md b/docs/api/flink/Function.md
index 567007de7d..8c63885f94 100644
--- a/docs/api/flink/Function.md
+++ b/docs/api/flink/Function.md
@@ -714,7 +714,7 @@ The optional forth parameter controls the buffer accuracy
and style. Buffer accu
- `endcap=round|flat|square` : End cap style (default is `round`). `butt` is
an accepted synonym for `flat`.
- `join=round|mitre|bevel` : Join style (default is `round`). `miter` is an
accepted synonym for `mitre`.
- `mitre_limit=#.#` : mitre ratio limit and it only affects mitred join style.
`miter_limit` is an accepted synonym for `mitre_limit`.
-- `side=both|left|right` : The option `left` or `right` enables a single-sided
buffer operation on the geometry, with the buffered side aligned according to
the direction of the line. This functionality is specific to LINESTRING
geometry and has no impact on POINT or POLYGON geometries. By default, square
end caps are applied.
+- `side=both|left|right` : Defaults to `both`. Setting `left` or `right`
enables a single-sided buffer operation on the geometry, with the buffered side
aligned according to the direction of the line. This functionality is specific
to LINESTRING geometry and has no impact on POINT or POLYGON geometries. By
default, square end caps are applied when `left` or `right` are specified.
!!!note
`ST_Buffer` throws an `IllegalArgumentException` if the correct format,
parameters, or options are not provided.
diff --git a/docs/api/snowflake/vector-data/Function.md
b/docs/api/snowflake/vector-data/Function.md
index de22dcc5a7..34b06a9e96 100644
--- a/docs/api/snowflake/vector-data/Function.md
+++ b/docs/api/snowflake/vector-data/Function.md
@@ -546,7 +546,7 @@ The optional forth parameter controls the buffer accuracy
and style. Buffer accu
- `endcap=round|flat|square` : End cap style (default is `round`). `butt` is
an accepted synonym for `flat`.
- `join=round|mitre|bevel` : Join style (default is `round`). `miter` is an
accepted synonym for `mitre`.
- `mitre_limit=#.#` : mitre ratio limit and it only affects mitred join style.
`miter_limit` is an accepted synonym for `mitre_limit`.
-- `side=both|left|right` : The option `left` or `right` enables a single-sided
buffer operation on the geometry, with the buffered side aligned according to
the direction of the line. This functionality is specific to LINESTRING
geometry and has no impact on POINT or POLYGON geometries. By default, square
end caps are applied.
+- `side=both|left|right` : Defaults to `both`. Setting `left` or `right`
enables a single-sided buffer operation on the geometry, with the buffered side
aligned according to the direction of the line. This functionality is specific
to LINESTRING geometry and has no impact on POINT or POLYGON geometries. By
default, square end caps are applied when `left` or `right` are specified.
!!!note
`ST_Buffer` throws an `IllegalArgumentException` if the correct format,
parameters, or options are not provided.
diff --git a/docs/api/sql/Function.md b/docs/api/sql/Function.md
index c95ae468d8..7f3e2c08dc 100644
--- a/docs/api/sql/Function.md
+++ b/docs/api/sql/Function.md
@@ -788,7 +788,7 @@ The optional forth parameter controls the buffer accuracy
and style. Buffer accu
- `endcap=round|flat|square` : End cap style (default is `round`). `butt` is
an accepted synonym for `flat`.
- `join=round|mitre|bevel` : Join style (default is `round`). `miter` is an
accepted synonym for `mitre`.
- `mitre_limit=#.#` : mitre ratio limit and it only affects mitred join style.
`miter_limit` is an accepted synonym for `mitre_limit`.
-- `side=both|left|right` : The option `left` or `right` enables a single-sided
buffer operation on the geometry, with the buffered side aligned according to
the direction of the line. This functionality is specific to LINESTRING
geometry and has no impact on POINT or POLYGON geometries. By default, square
end caps are applied.
+- `side=both|left|right` : Defaults to `both`. Setting `left` or `right`
enables a single-sided buffer operation on the geometry, with the buffered side
aligned according to the direction of the line. This functionality is specific
to LINESTRING geometry and has no impact on POINT or POLYGON geometries. By
default, square end caps are applied when `left` or `right` are specified.
!!!note
`ST_Buffer` throws an `IllegalArgumentException` if the correct format,
parameters, or options are not provided.
diff --git a/python/sedona/geopandas/base.py b/python/sedona/geopandas/base.py
index 040287d862..3c874816b7 100644
--- a/python/sedona/geopandas/base.py
+++ b/python/sedona/geopandas/base.py
@@ -2177,6 +2177,43 @@ class GeoFrame(metaclass=ABCMeta):
single_sided=False,
**kwargs,
):
+ """
+ Returns a GeoSeries with all geometries buffered by the specified
distance.
+
+ Parameters
+ ----------
+ distance : float
+ The distance to buffer by. Negative distances will create inward
buffers.
+ resolution : int, default 16
+ The resolution of the buffer around each vertex. Specifies the
number of
+ linear segments in a quarter circle in the approximation of
circular arcs.
+ cap_style : str, default "round"
+ The style of the buffer cap. One of 'round', 'flat', 'square'.
+ join_style : str, default "round"
+ The style of the buffer join. One of 'round', 'mitre', 'bevel'.
+ mitre_limit : float, default 5.0
+ The mitre limit ratio for joins when join_style='mitre'.
+ single_sided : bool, default False
+ Whether to create a single-sided buffer. In Sedona, True will
default to left-sided buffer.
+ However, 'right' may be specified to use a right-sided buffer.
+
+ Returns
+ -------
+ GeoSeries
+ A new GeoSeries with buffered geometries.
+
+ Examples
+ --------
+ >>> from shapely.geometry import Point
+ >>> from sedona.geopandas import GeoDataFrame
+ >>>
+ >>> data = {
+ ... 'geometry': [Point(0, 0), Point(1, 1)],
+ ... 'value': [1, 2]
+ ... }
+ >>> gdf = GeoDataFrame(data)
+ >>> buffered = gdf.buffer(0.5)
+ """
return _delegate_to_geometry_column(
"buffer",
self,
diff --git a/python/sedona/geopandas/geoseries.py
b/python/sedona/geopandas/geoseries.py
index d6fbb15d5c..54bf9ecc6a 100644
--- a/python/sedona/geopandas/geoseries.py
+++ b/python/sedona/geopandas/geoseries.py
@@ -1451,7 +1451,28 @@ class GeoSeries(GeoFrame, pspd.Series):
single_sided=False,
**kwargs,
) -> "GeoSeries":
- spark_col = stf.ST_Buffer(self.spark.column, distance)
+ if single_sided:
+ # Reverse the following logic in
common/src/main/java/org/apache/sedona/common/Functions.java buffer() to avoid
negating the distance
+ # if (bufferParameters.isSingleSided()
+ # && (params.toLowerCase().contains("left") && radius < 0
+ # || params.toLowerCase().contains("right") && radius >
0)) {
+ # radius = -radius;
+ # }
+ side = "left" if distance >= 0 else "right"
+ else:
+ side = "both"
+ assert side in [
+ "left",
+ "right",
+ "both",
+ ], "single-sided must be one of 'left', 'right', or 'both', True, or
False"
+
+ parameters = F.lit(
+ f"quad_segs={resolution} endcap={cap_style} join={join_style}
mitre_limit={mitre_limit} side={side}"
+ )
+ spark_col = stf.ST_Buffer(
+ self.spark.column, distance, useSpheroid=False,
parameters=parameters
+ )
return self._query_geometry_column(
spark_col,
returns_geom=True,
diff --git a/python/sedona/spark/sql/st_functions.py
b/python/sedona/spark/sql/st_functions.py
index 62a76945f6..43153377b7 100644
--- a/python/sedona/spark/sql/st_functions.py
+++ b/python/sedona/spark/sql/st_functions.py
@@ -335,7 +335,7 @@ def ST_Boundary(geometry: ColumnOrName) -> Column:
def ST_Buffer(
geometry: ColumnOrName,
buffer: ColumnOrNameOrNumber,
- useSpheroid: Optional[Union[ColumnOrName, bool]] = None,
+ useSpheroid: Optional[Union[ColumnOrName, bool]] = False,
parameters: Optional[Union[ColumnOrName, str]] = None,
) -> Column:
"""Calculate a geometry that represents all points whose distance from the
diff --git a/python/tests/geopandas/test_geodataframe.py
b/python/tests/geopandas/test_geodataframe.py
index 0c649d7909..6c7b7e7c11 100644
--- a/python/tests/geopandas/test_geodataframe.py
+++ b/python/tests/geopandas/test_geodataframe.py
@@ -329,31 +329,21 @@ class TestDataframe(TestGeopandasBase):
self.assert_almost_equal(area_values[1], 4.0)
def test_buffer(self):
- # Create a GeoDataFrame with geometries to test buffer operation
-
- # Create input geometries
point = Point(0, 0)
square = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
- data = {"geometry1": [point, square], "id": [1, 2], "value": ["a",
"b"]}
- df = GeoDataFrame(data, geometry="geometry1")
+ data = {"geometry": [point, square], "id": [1, 2], "value": ["a", "b"]}
+ df = GeoDataFrame(data)
- # Apply buffer with distance 0.5
result = df.buffer(0.5)
- # Verify result is a GeoDataFrame
assert type(result) is GeoSeries
-
- # Convert to pandas to extract individual geometries
- pd_series = result.to_pandas()
-
# Calculate areas to verify buffer was applied correctly
# Point buffer with radius 0.5 should have area approximately π * 0.5²
≈ 0.785
# Square buffer with radius 0.5 should expand the 1x1 square to 2x2
square with rounded corners
- areas = [geom.area for geom in pd_series]
# Check that square buffer area is greater than original (1.0)
- assert areas[1] > 1.0
+ assert result.area[1] > 1.0
def test_to_parquet(self):
pass
diff --git a/python/tests/geopandas/test_geoseries.py
b/python/tests/geopandas/test_geoseries.py
index 76e7eaf164..261ac304fb 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -105,19 +105,25 @@ class TestGeoSeries(TestGeopandasBase):
assert_series_equal(result, df_result)
def test_buffer(self):
- result = self.geoseries.buffer(1)
+
+ s = GeoSeries(
+ [
+ Point(0, 0),
+ LineString([(1, -1), (1, 0), (2, 0), (2, 1)]),
+ Polygon([(3, -1), (4, 0), (3, 1)]),
+ ]
+ )
+ result = s.buffer(0.2)
expected = [
- "POLYGON ((3.300000000000000 -1.000000000000000, 3.280785280403230
-1.195090322016128, 3.223879532511287 -1.382683432365090, 3.131469612302545
-1.555570233019602, 3.007106781186547 -1.707106781186547, 2.855570233019602
-1.831469612302545, 2.682683432365089 -1.923879532511287, 2.495090322016128
-1.980785280403230, 2.300000000000000 -2.000000000000000, 2.104909677983872
-1.980785280403230, 1.917316567634910 -1.923879532511287, 1.744429766980398
-1.831469612302545, 1.59289321881 [...]
- "POLYGON ((0.986393923832144 -3.164398987305357, 0.935367989801224
-3.353676015097457, 0.848396388482656 -3.529361471973156, 0.728821389740875
-3.684703864350261, 0.581238193719096 -3.813733471206735, 0.411318339874827
-3.911491757111723, 0.225591752899151 -3.974221925961374, 0.031195801372873
-3.999513292546280, -0.164398987305357 -3.986393923832144, -0.353676015097457
-3.935367989801224, -0.529361471973156 -3.848396388482656, -0.684703864350260
-3.728821389740875, -0.813733 [...]
- "POLYGON ((-0.260059926604056 -1.672672793996312,
-0.403493516968407 -1.802608257932399, -0.569270104475049 -1.902480890158382,
-0.751180291696993 -1.968549819451744, -0.942410374326119 -1.998340340272165,
-1.135797558140999 -1.990736606370705, -1.324098251632999 -1.946023426395157,
-1.500259385009482 -1.865875595977814, -1.657682592935656 -1.753295165887471,
-1.790471365675451 -1.612498995956065, -1.893651911234561 -1.448760806607280,
-1.963359455800552 -1.268213644171327, - [...]
- "POLYGON ((-0.844303230213814 -1.983056850984667,
-0.942410374326119 -1.998340340272165, -1.135797558140999 -1.990736606370705,
-1.324098251632999 -1.946023426395157, -1.500259385009482 -1.865875595977814,
-1.657682592935656 -1.753295165887471, -1.790471365675451 -1.612498995956065,
-1.893651911234561 -1.448760806607280, -1.963359455800552 -1.268213644171327,
-1.996983004332570 -1.077620158927971, -1.993263139087243 -0.884119300439822,
-1.293263139087243 5.115880699560178, -1 [...]
+ "POLYGON ((0.2 0, 0.1990369453344394 -0.0196034280659121,
0.1961570560806461 -0.0390180644032256, 0.1913880671464418 -0.0580569354508925,
0.1847759065022574 -0.076536686473018, 0.176384252869671 -0.0942793473651995,
0.1662939224605091 -0.1111140466039204, 0.1546020906725474 -0.1268786568327291,
0.1414213562373095 -0.1414213562373095, 0.1268786568327291 -0.1546020906725474,
0.1111140466039205 -0.1662939224605091, 0.0942793473651996 -0.176384252869671,
0.076536686473018 -0.1847 [...]
+ "POLYGON ((0.8 0, 0.8009630546655606 0.0196034280659122,
0.803842943919354 0.0390180644032257, 0.8086119328535583 0.0580569354508925,
0.8152240934977426 0.076536686473018, 0.823615747130329 0.0942793473651996,
0.8337060775394909 0.1111140466039204, 0.8453979093274526 0.1268786568327291,
0.8585786437626906 0.1414213562373095, 0.8731213431672709 0.1546020906725474,
0.8888859533960796 0.1662939224605091, 0.9057206526348005 0.176384252869671,
0.9234633135269821 0.1847759065022574 [...]
+ "POLYGON ((2.8 -1, 2.8 1, 2.8009630546655604 1.019603428065912,
2.8038429439193537 1.0390180644032256, 2.8086119328535584 1.0580569354508924,
2.8152240934977426 1.076536686473018, 2.823615747130329 1.0942793473651995,
2.833706077539491 1.1111140466039204, 2.8453979093274526 1.1268786568327291,
2.8585786437626903 1.1414213562373094, 2.873121343167271 1.1546020906725474,
2.8888859533960796 1.1662939224605091, 2.9057206526348005 1.176384252869671,
2.923463313526982 1.18477590650 [...]
]
expected = gpd.GeoSeries([wkt.loads(wkt_str) for wkt_str in expected])
- assert result.count() > 0
self.check_sgpd_equals_gpd(result, expected)
# Check that GeoDataFrame works too
- df_result = self.geoseries.to_geoframe().buffer(1)
+ df_result = s.to_geoframe().buffer(0.2)
self.check_sgpd_equals_gpd(df_result, expected)
def test_simplify(self):
diff --git a/python/tests/geopandas/test_match_geopandas_series.py
b/python/tests/geopandas/test_match_geopandas_series.py
index ddb71752d5..6420643ed1 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -189,11 +189,6 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
self.check_pd_series_equal(sgpd_result, gpd_result)
def test_buffer(self):
- buffer = self.g1.buffer(0.2)
- assert buffer is not None
- assert type(buffer) is GeoSeries
- assert buffer.count() == 2
-
for geom in self.geoms:
dist = 0.2
sgpd_result = GeoSeries(geom).buffer(dist)
@@ -201,6 +196,35 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+ # Check that the parameters work properly
+ sgpd_result = GeoSeries(self.linestrings).buffer(
+ 0.2, resolution=20, cap_style="flat", join_style="bevel"
+ )
+ gpd_result = gpd.GeoSeries(self.linestrings).buffer(
+ 0.2, resolution=20, cap_style="flat", join_style="bevel"
+ )
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+ sgpd_result = GeoSeries(self.linestrings).buffer(0.2,
single_sided=True)
+ gpd_result = gpd.GeoSeries(self.linestrings).buffer(0.2,
single_sided=True)
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+ sgpd_result = GeoSeries(self.linestrings).buffer(
+ 0.2, join_style="mitre", mitre_limit=10.0
+ )
+ gpd_result = gpd.GeoSeries(self.linestrings).buffer(
+ 0.2, join_style="mitre", mitre_limit=10.0
+ )
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+ sgpd_result = GeoSeries(self.linestrings).buffer(
+ 0.2, single_sided=True, cap_style="round"
+ )
+ gpd_result = gpd.GeoSeries(self.linestrings).buffer(
+ 0.2, single_sided=True, cap_style="round"
+ )
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
def test_buffer_then_area(self):
area = self.g1.buffer(0.2).area
assert area is not None