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/08/26 03:50:10 UTC

(sedona) branch master updated: [GH-3281] GeoPandas: implement from_features (#3282)

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 9b56c3be084 [GH-3281] GeoPandas: implement from_features (#3282)
9b56c3be084 is described below

commit 9b56c3be0841de8b254153a4a1fcfff73e05cb64
Author: Jia Yu <ji...@apache.org>
AuthorDate: Tue Aug 25 20:50:04 2026 -0700

    [GH-3281] GeoPandas: implement from_features (#3282)
---
 docs/setup/release-notes.md                   |  1 +
 python/sedona/spark/geopandas/geodataframe.py | 98 ++++++++++++++++++++++++++-
 python/tests/geopandas/test_geodataframe.py   | 97 ++++++++++++++++++++++++++
 3 files changed, 194 insertions(+), 2 deletions(-)

diff --git a/docs/setup/release-notes.md b/docs/setup/release-notes.md
index 9dc452e3a8e..50f07e4728e 100644
--- a/docs/setup/release-notes.md
+++ b/docs/setup/release-notes.md
@@ -50,6 +50,7 @@
 * [<a href='https://github.com/apache/sedona/issues/3257'>GH-3257</a>] - Implement distributed GeoSeries and GeoDataFrame Hilbert-distance spatial ordering
 * [<a href='https://github.com/apache/sedona/issues/3260'>GH-3260</a>] - Implement distributed GeoSeries and GeoDataFrame identical-geometry equality
 * [<a href='https://github.com/apache/sedona/issues/3273'>GH-3273</a>] - Implement distributed GeoSeries and GeoDataFrame polygonal coverage validation and invalid-edge diagnostics
+* [<a href='https://github.com/apache/sedona/issues/3281'>GH-3281</a>] - Implement `GeoDataFrame.from_features` for in-memory GeoJSON-like features
 
 ### Bug Fixes
 
diff --git a/python/sedona/spark/geopandas/geodataframe.py b/python/sedona/spark/geopandas/geodataframe.py
index 9a0804d8ee5..f87f384ed89 100644
--- a/python/sedona/spark/geopandas/geodataframe.py
+++ b/python/sedona/spark/geopandas/geodataframe.py
@@ -1485,9 +1485,103 @@ class GeoDataFrame(GeoFrame, pspd.DataFrame):
 
     @classmethod
     def from_features(
-        cls, features, crs: Any | None = None, columns: Iterable[str] | None = None
+        cls,
+        features,
+        crs: Any | None = None,
+        columns: typing.Iterable[str] | None = None,
     ) -> GeoDataFrame:
-        raise NotImplementedError("from_features() is not implemented yet.")
+        """Construct a GeoDataFrame from GeoJSON-like features.
+
+        .. versionadded:: 2.0.0
+
+        Parameters
+        ----------
+        features
+            An iterable of feature mappings or objects implementing
+            ``__geo_interface__``; a FeatureCollection dictionary; or an object
+            whose ``__geo_interface__`` is a FeatureCollection.
+        crs : str, dict, pyproj.CRS, optional
+            Coordinate reference system to assign to the resulting frame. The
+            coordinates are not transformed.
+        columns : iterable of str, optional
+            Column names to include and their order in the resulting frame.
+
+        Returns
+        -------
+        GeoDataFrame
+
+        Notes
+        -----
+        This constructor first materializes all features in a local GeoPandas
+        GeoDataFrame on the driver. It is intended for small in-memory feature
+        collections. For large datasets, use Sedona's distributed GeoJSON or
+        GeoParquet Spark data source instead.
+
+        GeoPandas controls feature parsing and column selection; feature ``id``,
+        ``bbox``, and collection metadata are not added as columns. Once the
+        result is distributed, property values follow Spark schema inference:
+        nested mappings become structs, and columns with incompatible mixed
+        types are not supported.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoDataFrame
+        >>> feature_collection = {
+        ...     "type": "FeatureCollection",
+        ...     "features": [
+        ...         {
+        ...             "type": "Feature",
+        ...             "properties": {"name": "first"},
+        ...             "geometry": {
+        ...                 "type": "Point",
+        ...                 "coordinates": (1.0, 2.0),
+        ...             },
+        ...         },
+        ...         {
+        ...             "type": "Feature",
+        ...             "properties": {"name": "second"},
+        ...             "geometry": {
+        ...                 "type": "Point",
+        ...                 "coordinates": (2.0, 1.0),
+        ...             },
+        ...         },
+        ...     ],
+        ... }
+        >>> GeoDataFrame.from_features(feature_collection)
+              geometry    name
+        0  POINT (1 2)   first
+        1  POINT (2 1)  second
+        """
+        local = gpd.GeoDataFrame.from_features(
+            features,
+            crs=crs,
+            columns=columns,
+        )
+
+        # Spark 3 pandas-on-Spark only checks the first object value when
+        # looking for a user-defined type. Move one geometry forward for
+        # schema inference, then restore the GeoPandas RangeIndex order.
+        restore_order = False
+        geometry_name = local._geometry_column_name
+        if geometry_name is not None and len(local) > 0:
+            missing = local[geometry_name].isna().to_numpy()
+            non_missing = np.flatnonzero(~missing)
+            if missing[0] and len(non_missing) > 0:
+                first_geometry = non_missing[0]
+                order = np.concatenate(
+                    (
+                        [first_geometry],
+                        np.arange(first_geometry),
+                        np.arange(first_geometry + 1, len(local)),
+                    )
+                )
+                local = local.iloc[order]
+                restore_order = True
+
+        result = cls(local)
+        if restore_order:
+            result.sort_index(inplace=True)
+        return result
 
     @classmethod
     def from_postgis(
diff --git a/python/tests/geopandas/test_geodataframe.py b/python/tests/geopandas/test_geodataframe.py
index f2cdc07efa4..140b0cc1d25 100644
--- a/python/tests/geopandas/test_geodataframe.py
+++ b/python/tests/geopandas/test_geodataframe.py
@@ -43,6 +43,103 @@ from pandas.testing import assert_frame_equal
 from packaging.version import parse as parse_version
 
 
+class TestGeoDataFrameFromFeatures(TestGeopandasBase):
+    def test_input_forms(self):
+        feature_collection = {
+            "type": "FeatureCollection",
+            "features": [
+                {
+                    "type": "Feature",
+                    "properties": {"name": "first"},
+                    "geometry": {"type": "Point", "coordinates": (1.0, 2.0)},
+                },
+                {
+                    "type": "Feature",
+                    "properties": {"name": "second"},
+                    "geometry": {"type": "Point", "coordinates": (2.0, 1.0)},
+                },
+            ],
+        }
+
+        class FeatureCollection:
+            __geo_interface__ = feature_collection
+
+        class Feature:
+            def __init__(self, mapping):
+                self.__geo_interface__ = mapping
+
+        expected = gpd.GeoDataFrame.from_features(feature_collection)
+        inputs = [
+            feature_collection,
+            (feature for feature in feature_collection["features"]),
+            FeatureCollection(),
+            [Feature(feature) for feature in feature_collection["features"]],
+        ]
+
+        for features in inputs:
+            result = GeoDataFrame.from_features(features)
+            self.check_sgpd_df_equals_gpd_df(result, expected)
+
+    def test_columns_crs_and_nulls_match_geopandas(self):
+        from geopandas.testing import assert_geodataframe_equal
+
+        features = {
+            "type": "FeatureCollection",
+            "features": [
+                {
+                    "id": "first",
+                    "type": "Feature",
+                    "properties": {"other": 2},
+                    "geometry": None,
+                },
+                {
+                    "id": "second",
+                    "type": "Feature",
+                    "properties": {"value": 1},
+                    "geometry": {"type": "Point", "coordinates": (1.0, 2.0)},
+                },
+                {
+                    "id": "third",
+                    "type": "Feature",
+                    "properties": None,
+                    "geometry": {
+                        "type": "LineString",
+                        "coordinates": ((0.0, 0.0), (1.0, 1.0)),
+                    },
+                },
+            ],
+        }
+        kwargs = {
+            "crs": "EPSG:4326",
+            "columns": ["other", "geometry", "value"],
+        }
+
+        result = GeoDataFrame.from_features(features, **kwargs)
+        expected = gpd.GeoDataFrame.from_features(features, **kwargs)
+        materialized = result.to_geopandas()
+
+        assert list(result.columns) == list(expected.columns)
+        assert result.crs == expected.crs
+        assert materialized.index.tolist() == expected.index.tolist()
+        assert_geodataframe_equal(materialized, expected)
+
+    def test_empty_with_schema(self):
+        expected = gpd.GeoDataFrame.from_features(
+            [],
+            crs="EPSG:4326",
+            columns=["geometry", "name"],
+        )
+        result = GeoDataFrame.from_features(
+            [],
+            crs="EPSG:4326",
+            columns=["geometry", "name"],
+        )
+
+        assert list(result.columns) == list(expected.columns)
+        assert result.crs == expected.crs
+        assert len(result) == 0
+
+
 @pytest.mark.skipif(
     parse_version(shapely.__version__) < parse_version("2.0.0"),
     reason=f"Tests require shapely>=2.0.0, but found v{shapely.__version__}",