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"):