You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/07/22 19:19:26 UTC
(sedona) branch master updated: [GH-3143] Implement distributed GeoPandas scale (#3145)
This is an automated email from the ASF dual-hosted git repository.
jiayuasu 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 b8f959f63d3 [GH-3143] Implement distributed GeoPandas scale (#3145)
b8f959f63d3 is described below
commit b8f959f63d39c4b748c667aa1e93875fadeb2897
Author: Jia Yu <ji...@apache.org>
AuthorDate: Wed Jul 22 12:19:21 2026 -0700
[GH-3143] Implement distributed GeoPandas scale (#3145)
---
python/sedona/spark/geopandas/base.py | 46 ++++++
python/sedona/spark/geopandas/geoseries.py | 97 ++++++++++++
python/tests/geopandas/test_geoseries.py | 170 +++++++++++++++++++++
.../tests/geopandas/test_match_geopandas_series.py | 55 +++++++
4 files changed, 368 insertions(+)
diff --git a/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py
index da03256b88e..f0206c3f526 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -1479,6 +1479,52 @@ class GeoFrame(metaclass=ABCMeta):
"""
return _delegate_to_geometry_column("rotate", self, angle, origin, use_radians)
+ def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center"):
+ """Return a ``GeoSeries`` with scaled geometries.
+
+ Each geometry is scaled independently around its origin. The default
+ origin is the center of the geometry's bounding box.
+
+ Parameters
+ ----------
+ xfact : float, default 1.0
+ Scaling factor for the x dimension.
+ yfact : float, default 1.0
+ Scaling factor for the y dimension.
+ zfact : float, default 1.0
+ Scaling factor for the z dimension.
+ origin : {"center", "centroid"}, Point, or tuple, default "center"
+ The scaling origin. ``"center"`` uses each geometry's bounding-box
+ center and ``"centroid"`` uses each geometry's centroid. A 2D or
+ 3D Shapely Point or coordinate tuple may also be supplied. The z
+ origin is 0 for keyword and 2D origins.
+
+ Returns
+ -------
+ GeoSeries
+ The scaled geometries.
+
+ Notes
+ -----
+ Results for mixed 2D/3D ``GeometryCollection`` objects, M or ZM
+ ordinates, NaN Z coordinates, non-finite factors, and non-finite
+ origin coordinates may differ from GeoPandas because Sedona uses JTS
+ while GeoPandas uses Shapely. This method applies Sedona's distributed
+ semantics and does not materialize geometries locally to emulate
+ Shapely.
+
+ Examples
+ --------
+ >>> from shapely.geometry import Point
+ >>> from sedona.spark.geopandas import GeoSeries
+ >>> s = GeoSeries([Point(1, 2), Point(-1, -2)])
+ >>> s.scale(xfact=2, yfact=3, origin=(0, 0))
+ 0 POINT (2 6)
+ 1 POINT (-2 -6)
+ dtype: geometry
+ """
+ return _delegate_to_geometry_column("scale", self, xfact, yfact, zfact, origin)
+
def force_2d(self):
"""Force the dimensionality of a geometry to 2D.
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 864e3e9b08d..2cd9dad8e35 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -1265,6 +1265,103 @@ class GeoSeries(GeoFrame, pspd.Series):
)
return self._query_geometry_column(spark_expr, returns_geom=True)
+ def scale(self, xfact=1.0, yfact=1.0, zfact=1.0, origin="center") -> "GeoSeries":
+ def normalize_factor(value, name):
+ if (
+ value is None
+ or isinstance(value, (str, bytes, bytearray))
+ or not np.isscalar(value)
+ ):
+ raise TypeError(f"'{name}' must be a numeric scalar")
+ try:
+ return float(value)
+ except (TypeError, ValueError, OverflowError) as exc:
+ raise TypeError(f"'{name}' must be a numeric scalar") from exc
+
+ xfact = normalize_factor(xfact, "xfact")
+ yfact = normalize_factor(yfact, "yfact")
+ zfact = normalize_factor(zfact, "zfact")
+
+ geometry = self.spark.column
+ if isinstance(origin, str):
+ if origin == "center":
+ origin_geometry = stf.ST_Centroid(stf.ST_Envelope(geometry))
+ elif origin == "centroid":
+ origin_geometry = stf.ST_Centroid(geometry)
+ else:
+ raise ValueError(
+ "origin must be 'center', 'centroid', a Point, or a "
+ f"two- or three-element tuple, got {origin!r}"
+ )
+ origin_x = stf.ST_X(origin_geometry)
+ origin_y = stf.ST_Y(origin_geometry)
+ origin_z = F.lit(0.0)
+ elif isinstance(origin, tuple):
+ if len(origin) not in (2, 3):
+ raise ValueError("'origin' tuple must contain two or three coordinates")
+ coordinates = []
+ for coordinate in origin:
+ if (
+ coordinate is None
+ or isinstance(coordinate, (str, bytes, bytearray))
+ or not np.isscalar(coordinate)
+ ):
+ raise TypeError(
+ "'origin' tuple must contain only numeric coordinates"
+ )
+ try:
+ coordinates.append(float(coordinate))
+ except (TypeError, ValueError, OverflowError) as exc:
+ raise TypeError(
+ "'origin' tuple must contain only numeric coordinates"
+ ) from exc
+ if len(coordinates) == 2:
+ coordinates.append(0.0)
+ origin_x, origin_y, origin_z = (F.lit(value) for value in coordinates)
+ elif isinstance(origin, shapely.geometry.Point):
+ if origin.is_empty:
+ raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
+ coordinates = tuple(origin.coords[0])
+ if len(coordinates) not in (2, 3) or (
+ len(coordinates) == 3 and not origin.has_z
+ ):
+ raise ValueError("'origin' Point must be a non-empty 2D or 3D Point")
+ if len(coordinates) == 2:
+ coordinates += (0.0,)
+ origin_x, origin_y, origin_z = (
+ F.lit(float(value)) for value in coordinates
+ )
+ else:
+ raise TypeError(
+ "origin must be 'center', 'centroid', a Point, or a "
+ "two- or three-element tuple"
+ )
+
+ xoff = origin_x - origin_x * F.lit(xfact)
+ yoff = origin_y - origin_y * F.lit(yfact)
+ zoff = origin_z - origin_z * F.lit(zfact)
+
+ # ST_Affine's Python wrapper order is geometry, a, b, d, e, xoff,
+ # yoff, c, f, g, h, i, zoff. Use its 3D overload for zfact while
+ # keeping 2D input geometries 2D.
+ scaled = stf.ST_Affine(
+ geometry,
+ xfact,
+ 0.0,
+ 0.0,
+ yfact,
+ xoff,
+ yoff,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ zfact,
+ zoff,
+ )
+ spark_expr = F.when(stf.ST_IsEmpty(geometry), geometry).otherwise(scaled)
+ return self._query_geometry_column(spark_expr, returns_geom=True)
+
def force_2d(self) -> "GeoSeries":
spark_expr = stf.ST_Force_2D(self.spark.column)
return self._query_geometry_column(spark_expr, returns_geom=True)
diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py
index bcca5fdfa69..b8f7eddae08 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -2101,6 +2101,176 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
with pytest.raises((ValueError, TypeError)):
s.rotate(90, origin="invalid")
+ @pytest.mark.parametrize(
+ "scale_kwargs",
+ [
+ pytest.param({}, id="defaults"),
+ pytest.param(
+ {
+ "xfact": np.float64(2),
+ "yfact": np.int64(3),
+ "zfact": np.float32(4),
+ "origin": "center",
+ },
+ id="custom-center",
+ ),
+ pytest.param(
+ {"xfact": 2, "yfact": 3, "zfact": 4, "origin": "centroid"},
+ id="custom-centroid",
+ ),
+ pytest.param(
+ {"xfact": -1, "yfact": 2, "zfact": -3, "origin": Point(1, 1)},
+ id="negative-point-origin",
+ ),
+ pytest.param(
+ {"xfact": 0, "yfact": 0, "zfact": 0, "origin": (0, 0)},
+ id="zero-tuple-origin",
+ ),
+ ],
+ )
+ def test_scale_factors_and_origins(self, scale_kwargs):
+ geoms = [
+ Point(2, 3),
+ LineString([(1, 2), (4, 6)]),
+ # This asymmetric polygon distinguishes bbox center from centroid.
+ Polygon([(0, 0), (4, 0), (0, 2), (0, 0)]),
+ ]
+ source = GeoSeries(geoms)
+ expected = gpd.GeoSeries(geoms).scale(**scale_kwargs)
+
+ result = source.scale(**scale_kwargs)
+
+ self.check_sgpd_equals_gpd(result, expected)
+
+ def test_scale_3d_default_origin(self):
+ result = GeoSeries([Point(1, 2, 3)]).scale(xfact=2, yfact=3, zfact=4)
+
+ point = result.to_geopandas().iloc[0]
+ # Keyword origins are 2D, so the point is its own x/y origin while
+ # z is scaled around zero.
+ assert tuple(point.coords[0]) == pytest.approx((1.0, 2.0, 12.0))
+
+ @pytest.mark.parametrize(
+ "origin,expected_coordinates",
+ [
+ pytest.param((1, 1), [(1, 4, 12), (7, 16, 36)], id="two-coordinate-tuple"),
+ pytest.param(Point(1, 1), [(1, 4, 12), (7, 16, 36)], id="2d-point"),
+ pytest.param(
+ (1, 1, 2), [(1, 4, 6), (7, 16, 30)], id="three-coordinate-tuple"
+ ),
+ pytest.param(Point(1, 1, 2), [(1, 4, 6), (7, 16, 30)], id="3d-point"),
+ ],
+ )
+ def test_scale_3d_explicit_origin(self, origin, expected_coordinates):
+ source = GeoSeries([LineString([(1, 2, 3), (4, 6, 9)])])
+
+ result = source.scale(xfact=2, yfact=3, zfact=4, origin=origin)
+
+ line = result.to_geopandas().iloc[0]
+ assert list(line.coords) == pytest.approx(expected_coordinates)
+
+ def test_scale_2d_stays_2d_with_3d_parameters(self):
+ geoms = [
+ Point(1, 2),
+ LineString([(0, 0), (2, 1)]),
+ Polygon([(0, 0), (2, 0), (1, 1), (0, 0)]),
+ ]
+ source = GeoSeries(geoms)
+ expected = gpd.GeoSeries(geoms).scale(2, 3, 4, origin=(1, 1, 2))
+
+ result = source.scale(2, 3, 4, origin=(1, 1, 2))
+
+ self.check_sgpd_equals_gpd(result, expected)
+ assert not any(geom.has_z for geom in result.to_geopandas())
+
+ def test_scale_preserves_metadata_empty_null_and_delegates(self):
+ geoms = [
+ Point(1, 2),
+ LineString([(0, 0), (2, 1)]),
+ Point(),
+ LineString(),
+ Polygon(),
+ None,
+ ]
+ index = pd.Index(
+ ["point", "line", "empty-point", "empty-line", "empty-polygon", "null"],
+ name="feature_id",
+ )
+ source = GeoSeries(geoms, index=index, crs="EPSG:3857", name="geometry")
+ expected = gpd.GeoSeries(
+ geoms, index=index, crs="EPSG:3857", name="geometry"
+ ).scale(2, 3, 4, origin="center")
+
+ result = source.scale(2, 3, 4, origin="center")
+
+ self.check_sgpd_equals_gpd(result, expected)
+ assert result.name is None
+ assert result.crs == source.crs == expected.crs
+ actual = result.to_geopandas()
+ assert actual.loc["empty-point"].is_empty
+ assert actual.loc["empty-point"].geom_type == "Point"
+ assert actual.loc["empty-line"].is_empty
+ assert actual.loc["empty-line"].geom_type == "LineString"
+ assert actual.loc["empty-polygon"].is_empty
+ assert actual.loc["empty-polygon"].geom_type == "Polygon"
+ assert actual.loc["null"] is None
+
+ srids = result._internal.spark_frame.select(
+ stf.ST_SRID(result.spark.column).alias("srid")
+ ).collect()
+ assert {row.srid for row in srids if row.srid is not None} == {3857}
+
+ frame_result = source.to_geoframe().scale(2, 3, 4, origin="center")
+ assert isinstance(frame_result, GeoSeries)
+ self.check_sgpd_equals_gpd(frame_result, expected)
+ assert frame_result.crs == source.crs
+
+ def test_scale_validates_factors_and_origin(self):
+ source = GeoSeries([Point(1, 2)])
+
+ invalid_factors = [
+ ("xfact", None),
+ ("yfact", "2"),
+ ("zfact", np.array([2])),
+ ("xfact", [2]),
+ ("yfact", 2 + 1j),
+ ]
+ for name, value in invalid_factors:
+ with pytest.raises(TypeError, match=rf"'{name}' must be a numeric scalar"):
+ source.scale(**{name: value})
+
+ with pytest.raises(TypeError, match="'xfact' must be a numeric scalar"):
+ source.scale(xfact=ps.Series([2.0]))
+
+ for origin in ("invalid", "CENTER"):
+ with pytest.raises(ValueError, match="origin must be"):
+ source.scale(origin=origin)
+
+ for origin in ((1,), (1, 2, 3, 4)):
+ with pytest.raises(
+ ValueError, match="tuple must contain two or three coordinates"
+ ):
+ source.scale(origin=origin)
+
+ for origin in ((1, "2"), (1, None)):
+ with pytest.raises(TypeError, match="only numeric coordinates"):
+ source.scale(origin=origin)
+
+ for origin in (None, [0, 0], Polygon([(0, 0), (1, 0), (0, 1)])):
+ with pytest.raises(TypeError, match="origin must be"):
+ source.scale(origin=origin)
+
+ with pytest.raises(ValueError, match="Point must be a non-empty 2D or 3D"):
+ source.scale(origin=Point())
+
+ # Operation-wide arguments are validated even when there is no
+ # non-empty geometry on which to apply the transformation.
+ empty_source = GeoSeries([Polygon(), None])
+ with pytest.raises(TypeError, match="'xfact' must be a numeric scalar"):
+ empty_source.scale(xfact=None)
+ with pytest.raises(ValueError, match="origin must be"):
+ empty_source.scale(origin="invalid")
+
def test_force_2d(self):
s = sgpd.GeoSeries(
[
diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py
index 1d5516c051b..ccd23157ca0 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -1071,6 +1071,61 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
gpd_result = gpd.GeoSeries(non_empty).rotate(angle, **origin_kwargs)
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+ @pytest.mark.parametrize(
+ "xfact,yfact",
+ [
+ (2.0, 0.5),
+ (-1.5, -0.5),
+ (0.0, 1.0),
+ ],
+ ids=["positive", "negative", "zero"],
+ )
+ @pytest.mark.parametrize(
+ "origin",
+ ["center", "centroid", (2.0, -1.0)],
+ ids=["center", "centroid", "explicit"],
+ )
+ def test_scale_2d(self, xfact, yfact, origin):
+ geoms = [
+ self.points[2],
+ self.linestrings[2],
+ self.polygons[2],
+ self.multipoints[2],
+ self.multilinestrings[2],
+ self.multipolygons[1],
+ self.geomcollection[1],
+ ]
+
+ sgpd_result = GeoSeries(geoms).scale(xfact=xfact, yfact=yfact, origin=origin)
+ gpd_result = gpd.GeoSeries(geoms).scale(xfact=xfact, yfact=yfact, origin=origin)
+
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
+ def test_scale_3d(self):
+ polygon = Polygon([(0, 0, 1), (2, 0, 2), (1, 2, 4), (0, 0, 1)])
+ geoms = [
+ Point(1, 2, 3),
+ LineString([(0, 0, 1), (2, 1, 4)]),
+ polygon,
+ MultiPoint([(0, 0, 1), (1, 2, 3)]),
+ MultiLineString([[(0, 0, 1), (2, 1, 3)], [(1, -1, 2), (3, 2, 4)]]),
+ MultiPolygon([polygon]),
+ GeometryCollection(
+ [Point(1, 2, 3), LineString([(0, 0, 1), (2, 1, 4)]), polygon]
+ ),
+ ]
+ scale_kwargs = {
+ "xfact": 2.0,
+ "yfact": -1.0,
+ "zfact": 0.0,
+ "origin": (1.0, -2.0, 3.0),
+ }
+
+ sgpd_result = GeoSeries(geoms).scale(**scale_kwargs)
+ gpd_result = gpd.GeoSeries(geoms).scale(**scale_kwargs)
+
+ self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+
def test_force_2d(self):
# force_2d was added from geopandas 1.0.0
if parse_version(gpd.__version__) < parse_version("1.0.0"):