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/29 06:34:27 UTC

(sedona) branch master updated: [GH-3196] GeoPandas: Add distributed sampling, point construction, and serialization (#3197)

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 7a6ba17d53a [GH-3196] GeoPandas: Add distributed sampling, point construction, and serialization (#3197)
7a6ba17d53a is described below

commit 7a6ba17d53a4f8fb1eb8561f9a70f51b34ea26c8
Author: Jia Yu <ji...@apache.org>
AuthorDate: Tue Jul 28 23:34:21 2026 -0700

    [GH-3196] GeoPandas: Add distributed sampling, point construction, and serialization (#3197)
---
 docs/tutorial/geopandas-api.md                     |  20 +-
 docs/tutorial/geopandas-api.zh.md                  |  17 +-
 python/sedona/spark/geopandas/__init__.py          |   1 +
 python/sedona/spark/geopandas/array.py             | 272 ++++++++++++++
 python/sedona/spark/geopandas/base.py              |  94 +++++
 python/sedona/spark/geopandas/geodataframe.py      | 159 +++++++-
 python/sedona/spark/geopandas/geoseries.py         | 398 +++++++++++++++++++--
 python/tests/geopandas/test_array.py               | 221 ++++++++++++
 python/tests/geopandas/test_geodataframe.py        | 110 ++++++
 python/tests/geopandas/test_geoseries.py           | 268 ++++++++++++++
 .../geopandas/test_match_geopandas_dataframe.py    |  41 +++
 .../tests/geopandas/test_match_geopandas_series.py |  73 ++++
 12 files changed, 1631 insertions(+), 43 deletions(-)

diff --git a/docs/tutorial/geopandas-api.md b/docs/tutorial/geopandas-api.md
index 22b6963771b..2fbeff1894a 100644
--- a/docs/tutorial/geopandas-api.md
+++ b/docs/tutorial/geopandas-api.md
@@ -337,6 +337,8 @@ The GeoPandas API for Apache Sedona implements the most commonly used GeoSeries
 - `intersects()`, `contains()`, `within()` - Spatial predicates
 - `intersection()` - Geometric intersection
 - `make_valid()` - Geometry validation and repair
+- `sample_points()` - Sample polygons by area and lines by length with native
+  distributed expressions
 - `cx` - Coordinate-based spatial filtering
 - `clip()` - Distributed geometry clipping
 - `sindex` - Spatial indexing (limited functionality)
@@ -374,10 +376,24 @@ only aggregate metadata and the API's single geometry result to the driver.
 ### Data Conversion
 
 - `to_geopandas()` - Convert to traditional GeoPandas
-- `to_wkb()`, `to_wkt()` - Convert to WKB/WKT formats
-- `from_xy()` - Create geometries from coordinates
+- `GeoDataFrame.to_wkb()`, `GeoDataFrame.to_wkt()` - Serialize every geometry
+  column to WKB/WKT in a distributed pandas-on-Spark DataFrame
+- `points_from_xy()`, `GeoSeries.from_xy()` - Create a distributed GeoSeries
+  from coordinate columns without collecting distributed inputs
 - `geom_type` - Get geometry types
 
+Unlike GeoPandas, `GeoDataFrame.to_wkb()` and `GeoDataFrame.to_wkt()` return a
+lazy pandas-on-Spark DataFrame. Call `.to_pandas()` on the result only when a
+local pandas DataFrame is required.
+
+`points_from_xy()` keeps pandas-on-Spark coordinate Series distributed.
+Local lists, NumPy arrays, and pandas objects are first materialized on the
+driver, so use distributed Series for large coordinate columns. Likewise, use
+a distributed `size` Series for large per-row `sample_points()` sizes.
+Sampling output and per-row intermediate work grow with the requested size;
+the current line sampler materializes the row's sampled points before
+collecting them into a MultiPoint.
+
 ## Complete Workflow Example
 
 ```python
diff --git a/docs/tutorial/geopandas-api.zh.md b/docs/tutorial/geopandas-api.zh.md
index 5100ea351c0..d235288bade 100644
--- a/docs/tutorial/geopandas-api.zh.md
+++ b/docs/tutorial/geopandas-api.zh.md
@@ -337,6 +337,7 @@ Apache Sedona 的 GeoPandas API 已实现最常用的 GeoSeries 与 GeoDataFrame
 - `intersects()`、`contains()`、`within()` —— 空间谓词
 - `intersection()` —— 几何相交
 - `make_valid()` —— 几何校验与修复
+- `sample_points()` —— 使用原生分布式表达式按面积对多边形采样、按长度对线采样
 - `cx` —— 基于坐标范围的空间筛选
 - `clip()` —— 分布式几何裁剪
 - `sindex` —— 空间索引(功能有限)
@@ -372,10 +373,22 @@ all_building_parts = collect(buildings.geometry)
 ### 数据转换
 
 - `to_geopandas()` —— 转换为传统 GeoPandas
-- `to_wkb()`、`to_wkt()` —— 转换为 WKB/WKT
-- `from_xy()` —— 通过坐标创建几何
+- `GeoDataFrame.to_wkb()`、`GeoDataFrame.to_wkt()` —— 在分布式
+  pandas-on-Spark DataFrame 中把所有几何列序列化为 WKB/WKT
+- `points_from_xy()`、`GeoSeries.from_xy()` —— 通过坐标列创建分布式
+  GeoSeries,且不收集分布式输入
 - `geom_type` —— 获取几何类型
 
+与 GeoPandas 不同,`GeoDataFrame.to_wkb()` 和 `GeoDataFrame.to_wkt()` 返回
+惰性的 pandas-on-Spark DataFrame。仅在需要本地 pandas DataFrame 时,才对结果
+调用 `.to_pandas()`。
+
+`points_from_xy()` 会让 pandas-on-Spark 坐标 Series 保持分布式执行。本地
+列表、NumPy 数组和 pandas 对象会先在 driver 上物化,因此大型坐标列应使用
+分布式 Series。类似地,大型逐行 `sample_points()` 数量向量也应使用分布式
+`size` Series。采样输出与逐行中间计算量会随请求的数量增长;当前线采样器会先
+物化该行的采样点,再把它们收集为 MultiPoint。
+
 ## 完整工作流示例
 
 ```python
diff --git a/python/sedona/spark/geopandas/__init__.py b/python/sedona/spark/geopandas/__init__.py
index a89d732f3f8..a5bac9d262a 100644
--- a/python/sedona/spark/geopandas/__init__.py
+++ b/python/sedona/spark/geopandas/__init__.py
@@ -22,6 +22,7 @@
 
 from sedona.spark.geopandas.geoseries import GeoSeries
 from sedona.spark.geopandas.geodataframe import GeoDataFrame
+from sedona.spark.geopandas.array import points_from_xy
 
 from sedona.spark.geopandas.tools import clip, sjoin
 
diff --git a/python/sedona/spark/geopandas/array.py b/python/sedona/spark/geopandas/array.py
new file mode 100644
index 00000000000..b7a875f8464
--- /dev/null
+++ b/python/sedona/spark/geopandas/array.py
@@ -0,0 +1,272 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Distributed geometry-array construction helpers."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import pyspark.pandas as pspd
+from pyproj import CRS
+from pyspark.pandas.frame import DataFrame as PandasOnSparkDataFrame
+from pyspark.pandas.internal import (
+    InternalField,
+    NATURAL_ORDER_COLUMN_NAME,
+    SPARK_DEFAULT_SERIES_NAME,
+)
+from pyspark.pandas.series import first_series
+from pyspark.pandas.utils import same_anchor, scol_for
+from pyspark.sql import functions as F
+from pyspark.sql.types import (
+    BooleanType,
+    NullType,
+    NumericType,
+    StructField,
+)
+
+from sedona.spark.geopandas._crs import with_crs_metadata
+from sedona.spark.sql import st_constructors as stc
+from sedona.spark.sql import st_functions as stf
+from sedona.spark.sql.types import GeometryType
+
+
+def _is_distributed_series(value: Any) -> bool:
+    return isinstance(value, pspd.Series)
+
+
+def _coordinate_expression(series: pspd.Series, label: str):
+    spark_type = series.spark.data_type
+    if not isinstance(spark_type, (NumericType, BooleanType, NullType)):
+        raise TypeError(
+            f"{label} must be a numeric pandas-on-Spark Series, "
+            f"got {spark_type.simpleString()}"
+        )
+
+    # GeoPandas converts missing coordinates to IEEE NaN before constructing
+    # points. Preserve that distinction from a missing geometry.
+    return F.coalesce(series.spark.column.cast("double"), F.lit(float("nan")))
+
+
+def _coordinate_scalar_expression(value: Any, label: str):
+    try:
+        array = np.asarray(value, dtype="float64")
+    except (TypeError, ValueError, OverflowError) as exc:
+        raise TypeError(
+            f"{label} must be a numeric scalar or pandas-on-Spark Series"
+        ) from exc
+    if array.ndim != 0:
+        raise TypeError(
+            f"{label} must be a numeric scalar when another coordinate is "
+            "distributed"
+        )
+    return F.lit(float(array))
+
+
+def _distributed_points_from_xy(
+    coordinates: list[Any],
+    labels: list[str],
+    crs: Any | None,
+    name: Any | None,
+):
+    distributed_coordinates = [
+        coordinate for coordinate in coordinates if _is_distributed_series(coordinate)
+    ]
+    source = distributed_coordinates[0]
+    if not all(
+        same_anchor(source, coordinate) for coordinate in distributed_coordinates[1:]
+    ):
+        raise ValueError(
+            "pandas-on-Spark coordinate Series must share the same distributed "
+            "frame and index; align them in one DataFrame before calling "
+            "points_from_xy"
+        )
+
+    expressions = [
+        (
+            _coordinate_expression(coordinate, label)
+            if _is_distributed_series(coordinate)
+            else _coordinate_scalar_expression(coordinate, label)
+        )
+        for coordinate, label in zip(coordinates, labels)
+    ]
+    point = (
+        stc.ST_PointZ(*expressions)
+        if len(expressions) == 3
+        else stc.ST_Point(*expressions)
+    )
+    normalized_crs = CRS.from_user_input(crs) if crs is not None else None
+    if normalized_crs is not None:
+        point = stf.ST_SetSRID(point, normalized_crs.to_epsg() or 0)
+
+    source_internal = source._internal
+    source_sdf = source_internal.spark_frame
+    result_name = SPARK_DEFAULT_SERIES_NAME
+    result_template = with_crs_metadata(
+        InternalField(
+            np.dtype("object"),
+            StructField(result_name, GeometryType(), nullable=True),
+        ),
+        normalized_crs,
+    )
+    result_sdf = source_sdf.select(
+        point.alias(result_name, metadata=result_template.metadata),
+        *source_internal.index_spark_columns,
+        scol_for(source_sdf, NATURAL_ORDER_COLUMN_NAME),
+    )
+    result_internal = source_internal.copy(
+        spark_frame=result_sdf,
+        index_spark_columns=[
+            scol_for(result_sdf, column_name)
+            for column_name in source_internal.index_spark_column_names
+        ],
+        column_labels=[(result_name,)],
+        data_spark_columns=[scol_for(result_sdf, result_name)],
+        data_fields=[InternalField(np.dtype("object"), result_sdf.schema[result_name])],
+        column_label_names=[None],
+    )
+
+    from sedona.spark.geopandas.geoseries import GeoSeries
+
+    result = first_series(PandasOnSparkDataFrame(result_internal)).rename(name)
+    return GeoSeries(result)
+
+
+def _local_coordinate_array(value: Any, label: str) -> np.ndarray:
+    try:
+        array = np.asarray(value, dtype="float64")
+    except (TypeError, ValueError, OverflowError) as exc:
+        raise TypeError(f"{label} must be an iterable of numeric values") from exc
+    if array.ndim > 1:
+        raise ValueError(f"{label} must be one-dimensional")
+    return array
+
+
+def _local_points_from_xy(
+    coordinates: list[Any],
+    labels: list[str],
+    crs: Any | None,
+    index,
+    name: Any | None,
+):
+    arrays = [
+        _local_coordinate_array(coordinate, label)
+        for coordinate, label in zip(coordinates, labels)
+    ]
+    try:
+        arrays = list(np.broadcast_arrays(*arrays))
+    except ValueError as exc:
+        lengths = ", ".join(
+            f"{label}={array.size}" for label, array in zip(labels, arrays)
+        )
+        raise ValueError(
+            f"Coordinate lengths are not broadcast-compatible: {lengths}"
+        ) from exc
+    if arrays[0].ndim == 0:
+        raise TypeError("at least one coordinate must be an iterable")
+
+    if index is None and all(isinstance(value, pd.Series) for value in coordinates):
+        first_index = coordinates[0].index
+        if all(value.index.equals(first_index) for value in coordinates[1:]):
+            index = first_index
+
+    frame = pd.DataFrame(
+        {label: array for label, array in zip(labels, arrays)},
+        index=index,
+    )
+    distributed = pspd.from_pandas(frame)
+    return _distributed_points_from_xy(
+        [distributed[label] for label in labels],
+        labels,
+        crs,
+        name,
+    )
+
+
+def _points_from_xy(
+    x,
+    y,
+    z=None,
+    crs=None,
+    *,
+    index=None,
+    name=None,
+):
+    coordinates = [x, y] if z is None else [x, y, z]
+    labels = ["x", "y"] if z is None else ["x", "y", "z"]
+    distributed = [_is_distributed_series(value) for value in coordinates]
+
+    if any(distributed):
+        if index is not None:
+            raise TypeError(
+                "index cannot be supplied with distributed coordinate Series; "
+                "their shared index is preserved automatically"
+            )
+        return _distributed_points_from_xy(coordinates, labels, crs, name)
+
+    return _local_points_from_xy(coordinates, labels, crs, index, name)
+
+
+def points_from_xy(x, y, z=None, crs=None):
+    """
+    Construct a distributed GeoSeries of Point geometries from coordinates.
+
+    Unlike :func:`geopandas.points_from_xy`, which returns an in-memory
+    ``GeometryArray``, this scalable counterpart returns a distributed
+    :class:`GeoSeries` so that its index and CRS metadata can be retained.
+
+    Parameters
+    ----------
+    x, y, z : iterable or pandas-on-Spark Series
+        Coordinate values. Distributed Series must come from the same frame;
+        numeric scalar coordinates are broadcast over that frame.
+    crs : value, optional
+        Coordinate Reference System accepted by
+        :meth:`pyproj.CRS.from_user_input`.
+
+    Returns
+    -------
+    GeoSeries
+        Distributed Point geometries. Shared distributed indexes, including
+        MultiIndexes and duplicate labels, are preserved.
+
+    Examples
+    --------
+    >>> import sedona.spark.geopandas as sgpd
+    >>> points = sgpd.points_from_xy([1, 2], [3, 4], crs="EPSG:4326")
+    >>> points
+    0    POINT (1 3)
+    1    POINT (2 4)
+    dtype: geometry
+
+    Notes
+    -----
+    Construction uses native ``ST_Point`` or ``ST_PointZ`` expressions. It
+    does not collect pandas-on-Spark inputs or execute a Python row UDF.
+
+    Local coordinates use NumPy broadcasting and are paired positionally, as
+    in GeoPandas. Local iterables, NumPy arrays, and pandas objects are
+    materialized on the driver and are intended for bounded inputs. Use
+    pandas-on-Spark Series for large coordinate columns.
+
+    Distributed Series are paired within their shared Spark plan and retain
+    that plan's index. Series from unrelated plans are rejected because they
+    have no safe distributed positional relationship.
+    """
+    return _points_from_xy(x, y, z=z, crs=crs)
diff --git a/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py
index fa62d08be47..a4272fae7b6 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -1608,6 +1608,100 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("reverse", self)
 
+    def sample_points(
+        self,
+        size,
+        method="uniform",
+        seed=None,
+        rng=None,
+        **kwargs,
+    ):
+        """Sample points from each geometry.
+
+        Polygonal geometries are sampled uniformly by area and linear
+        geometries uniformly by length. Unsupported and empty geometries
+        produce an empty ``MultiPoint``. Supported geometries also return
+        ``MultiPoint`` for every size, including zero and one. Sampling is
+        evaluated by native Spark and Sedona expressions and remains
+        distributed.
+
+        Parameters
+        ----------
+        size : int or array-like
+            Number of points to sample from each geometry. An array-like or
+            pandas-on-Spark ``Series`` supplies one positional size per row.
+            Local sequences are materialized on the driver; use a distributed
+            Series for large per-row size vectors.
+        method : str, default "uniform"
+            Sampling method. Sedona currently supports only ``"uniform"``.
+        seed : optional
+            Deprecated alias for ``rng``.
+        rng : optional
+            Any seed accepted by ``numpy.random.default_rng``. Fresh generators
+            initialized with the same seed produce reproducible results. Passing
+            an existing ``Generator`` or ``BitGenerator`` consumes one draw
+            immediately to derive the engine seed.
+        **kwargs
+            Accepted for GeoPandas signature compatibility and ignored for
+            uniform sampling.
+
+        Returns
+        -------
+        GeoSeries
+            Sampled points with the original index and CRS and the name
+            ``"sampled_points"``.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString, Polygon
+        >>> s = GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (2, 0)]),
+        ...     ]
+        ... )
+        >>> s.sample_points(size=3, rng=7).count_geometries()
+        0    3
+        1    3
+        dtype: int64
+
+        Notes
+        -----
+        Sedona and GeoPandas use different random number generators, so the
+        sampled coordinates are not expected to be identical for the same
+        seed. An integer seed is reused for every row, matching GeoPandas's
+        behavior in which congruent geometries receive the same relative sample
+        positions. With ``rng=None``, no reproducibility guarantee is made.
+
+        A stateful NumPy ``Generator`` or ``BitGenerator`` is reduced to one
+        engine seed and then varied deterministically by row position, so its
+        state advancement also differs. Validation of values in a distributed
+        ``size`` Series is lazy and errors are raised when the result is
+        evaluated.
+
+        Sampling work and per-row intermediate memory grow with ``size``. The
+        current line sampler materializes an array of sampled points and costs
+        roughly ``O(size * vertices)`` per geometry before collecting those
+        points into a MultiPoint. Unsupported nonempty geometry types produce
+        empty MultiPoints without GeoPandas's per-row warning, avoiding an eager
+        type scan.
+
+        Sedona follows GeoPandas 1.1.4 and later by always returning a
+        ``MultiPoint``. GeoPandas 1.1.3 and earlier instead return a ``Point``
+        for ``size=1`` and an empty ``GeometryCollection`` for ``size=0``, and
+        may collapse duplicate samples.
+        """
+        return _delegate_to_geometry_column(
+            "sample_points",
+            self,
+            size,
+            method=method,
+            seed=seed,
+            rng=rng,
+            **kwargs,
+        )
+
     def segmentize(self, max_segment_length):
         """Returns a ``GeoSeries`` with vertices added to line segments based on
         maximum segment length.
diff --git a/python/sedona/spark/geopandas/geodataframe.py b/python/sedona/spark/geopandas/geodataframe.py
index a5968e1892a..23124c95c0b 100644
--- a/python/sedona/spark/geopandas/geodataframe.py
+++ b/python/sedona/spark/geopandas/geodataframe.py
@@ -37,10 +37,13 @@ from pyspark.pandas.internal import (
     SPARK_INDEX_NAME_FORMAT,
 )
 from pyspark.pandas.utils import scol_for
+from pyspark.sql.column import Column
 from pyspark.sql import functions as F
 from pyspark.sql.types import (
+    BinaryType,
     BooleanType,
     ByteType,
+    DataType,
     DoubleType,
     FloatType,
     IntegerType,
@@ -57,6 +60,7 @@ from sedona.spark.geopandas.base import GeoFrame
 from sedona.spark.sql import st_aggregates as sta
 from sedona.spark.sql import st_constructors as stc
 from sedona.spark.sql import st_functions as stf
+from sedona.spark.sql.types import GeometryType
 
 from pandas.api.extensions import register_extension_dtype
 from geopandas.geodataframe import crs_mismatch_error
@@ -1560,11 +1564,158 @@ class GeoDataFrame(GeoFrame, pspd.DataFrame):
     ) -> dict:
         raise NotImplementedError("to_geo_dict() is not implemented yet.")
 
-    def to_wkb(self, hex: bool = False, **kwargs) -> pd.DataFrame:
-        raise NotImplementedError("to_wkb() is not implemented yet.")
+    def _serialize_geometry_columns(
+        self,
+        serializer: Callable[[Column], Column],
+        output_type: DataType,
+    ) -> pspd.DataFrame:
+        """Apply a native serializer to every geometry-typed column."""
+        # Resolve pending index/data expressions onto one Spark frame before
+        # building the serializer projection.
+        internal = self._internal.resolved_copy
+        serialized_columns = []
+        geometry_columns = []
+
+        # Build one projection for the whole frame. Sequential assignment would
+        # align each replacement Series and can multiply duplicate-index rows.
+        for spark_column, spark_name, field in zip(
+            internal.data_spark_columns,
+            internal.data_spark_column_names,
+            internal.data_fields,
+        ):
+            is_geometry = isinstance(field.spark_type, GeometryType)
+            geometry_columns.append(is_geometry)
+            serialized_columns.append(
+                serializer(spark_column).alias(spark_name)
+                if is_geometry
+                else spark_column
+            )
+
+        output_sdf = internal.spark_frame.select(
+            *[
+                scol_for(internal.spark_frame, name)
+                for name in internal.index_spark_column_names
+            ],
+            *serialized_columns,
+            scol_for(internal.spark_frame, NATURAL_ORDER_COLUMN_NAME),
+        )
+        output_schema = output_sdf.schema
+        output_fields = [
+            (
+                field.copy(
+                    dtype=np.dtype("object"),
+                    spark_type=output_type,
+                    nullable=output_schema[spark_name].nullable,
+                    metadata={},
+                )
+                if is_geometry
+                else field
+            )
+            for spark_name, field, is_geometry in zip(
+                internal.data_spark_column_names,
+                internal.data_fields,
+                geometry_columns,
+            )
+        ]
+        result_internal = internal.copy(
+            spark_frame=output_sdf,
+            index_spark_columns=[
+                scol_for(output_sdf, name) for name in internal.index_spark_column_names
+            ],
+            data_spark_columns=[
+                scol_for(output_sdf, name) for name in internal.data_spark_column_names
+            ],
+            data_fields=output_fields,
+        )
+        return PandasOnSparkDataFrame(result_internal)
+
+    @staticmethod
+    def _validate_serialization_kwargs(method_name: str, kwargs: dict) -> None:
+        if kwargs:
+            names = ", ".join(sorted(kwargs))
+            raise NotImplementedError(
+                f"GeoDataFrame.{method_name}() does not support Shapely keyword "
+                f"arguments in distributed execution: {names}"
+            )
+
+    def to_wkb(self, hex: bool = False, **kwargs) -> pspd.DataFrame:
+        """
+        Encode all geometry columns in the GeoDataFrame to WKB.
 
-    def to_wkt(self, **kwargs) -> pd.DataFrame:
-        raise NotImplementedError("to_wkt() is not implemented yet.")
+        Parameters
+        ----------
+        hex : bool, default False
+            If True, export WKB values as hexadecimal strings. By default,
+            values are returned as binary objects.
+        **kwargs
+            Shapely-specific serialization options are not supported by the
+            distributed implementation and raise ``NotImplementedError``.
+
+        Returns
+        -------
+        pyspark.pandas.DataFrame
+            A distributed DataFrame with every geometry-typed column encoded
+            as WKB and all other columns unchanged.
+
+        Notes
+        -----
+        Duplicate column labels remain subject to pandas-on-Spark's DataFrame
+        construction restrictions.
+
+        Unlike GeoPandas, this method returns a lazy pandas-on-Spark DataFrame.
+        Call ``to_pandas()`` on the result to materialize a local pandas
+        DataFrame.
+
+        See Also
+        --------
+        GeoDataFrame.to_wkt
+        GeoSeries.to_wkb
+        """
+        self._validate_serialization_kwargs("to_wkb", kwargs)
+        serializer, output_type = (
+            (lambda geometry: F.hex(stf.ST_AsBinary(geometry)), StringType())
+            if hex
+            else (stf.ST_AsBinary, BinaryType())
+        )
+        return self._serialize_geometry_columns(serializer, output_type)
+
+    def to_wkt(self, **kwargs) -> pspd.DataFrame:
+        """
+        Encode all geometry columns in the GeoDataFrame to WKT.
+
+        Parameters
+        ----------
+        **kwargs
+            Shapely-specific serialization options are not supported by the
+            distributed implementation and raise ``NotImplementedError``.
+
+        Returns
+        -------
+        pyspark.pandas.DataFrame
+            A distributed DataFrame with every geometry-typed column encoded
+            as WKT and all other columns unchanged.
+
+        Notes
+        -----
+        Duplicate column labels remain subject to pandas-on-Spark's DataFrame
+        construction restrictions.
+
+        Unlike GeoPandas, this method returns a lazy pandas-on-Spark DataFrame.
+        Call ``to_pandas()`` on the result to materialize a local pandas
+        DataFrame.
+
+        Sedona's native WKT writer preserves stored coordinate precision.
+        GeoPandas delegates to Shapely, whose default writer rounds coordinates
+        to six decimal places. Shapely-specific formatting keyword arguments
+        are not supported here.
+
+        See Also
+        --------
+        GeoDataFrame.to_wkb
+        GeoSeries.to_wkt
+        """
+        self._validate_serialization_kwargs("to_wkt", kwargs)
+        return self._serialize_geometry_columns(stf.ST_AsText, StringType())
 
     def to_arrow(
         self,
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 83e3a3e7c3e..f01414b5108 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import sys
+import operator
 import typing
 import warnings
 from typing import Any, Union, Literal, List
@@ -30,8 +31,15 @@ from pyspark.pandas import Series as PandasOnSparkSeries
 from pyspark.pandas.frame import DataFrame as PandasOnSparkDataFrame
 from pyspark.pandas.internal import InternalField, InternalFrame
 from pyspark.pandas.series import first_series
-from pyspark.pandas.utils import scol_for, verify_temp_column_name
-from pyspark.sql.types import NullType
+from pyspark.pandas.utils import same_anchor, scol_for, verify_temp_column_name
+from pyspark.sql.types import (
+    BooleanType,
+    IntegralType,
+    LongType,
+    NullType,
+    StructField,
+    StructType,
+)
 from sedona.spark.sql.types import GeometryType
 
 from sedona.spark.sql import st_aggregates as sta
@@ -42,6 +50,7 @@ from sedona.spark.sql import st_predicates as stp
 from pyspark.sql import Column as PySparkColumn
 from pyspark.sql import functions as F
 
+
 import shapely
 from shapely.geometry.base import BaseGeometry
 
@@ -125,6 +134,104 @@ def _normalize_affine_scalar(value, error_message: str) -> float:
     return _normalize_numeric_scalar(value, error_message)
 
 
+def _normalize_sample_size_scalar(size) -> int:
+    """Normalize one sample size without accepting boolean values."""
+    if isinstance(size, (bool, np.bool_)):
+        raise TypeError(
+            f"expected a sequence of integers or a single integer, got {size!r}"
+        )
+    try:
+        size = operator.index(size)
+    except TypeError as exc:
+        raise TypeError(
+            f"expected a sequence of integers or a single integer, got {size!r}"
+        ) from exc
+    if size < 0:
+        raise ValueError("negative dimensions are not allowed")
+    if size > 2_147_483_647:
+        raise ValueError("sample size must be at most 2147483647")
+    return size
+
+
+def _normalize_sample_seed(rng) -> int:
+    """Map a NumPy-compatible RNG argument to Sedona's positive seed range."""
+    generator = np.random.default_rng(rng)
+    return int(generator.integers(1, 2_147_483_647))
+
+
+def _attach_ordered_sequence_column(
+    sdf,
+    order_column: PySparkColumn,
+    sequence_column: str,
+):
+    """Attach dense positions without a single-partition window over all rows."""
+    # Use pandas-on-Spark's native implementation so it owns the child
+    # materialization lifecycle and avoids duplicate evaluation.
+    return InternalFrame.attach_distributed_sequence_column(
+        sdf.orderBy(order_column),
+        sequence_column,
+    )
+
+
+def _sample_points_expression(
+    geometry: PySparkColumn,
+    size: PySparkColumn,
+    seed: PySparkColumn,
+    fallback_srid: int,
+) -> PySparkColumn:
+    """Build native expressions for uniform polygon and line sampling."""
+    size = size.cast("long")
+    output_srid = F.coalesce(stf.ST_SRID(geometry), F.lit(fallback_srid))
+    empty_multipoint = stc.ST_GeomFromWKT(F.lit("MULTIPOINT EMPTY"), output_srid)
+    geometry_type = stf.ST_GeometryType(geometry)
+    is_polygonal = geometry_type.isin("ST_Polygon", "ST_MultiPolygon")
+    is_linear = geometry_type.isin("ST_LineString", "ST_MultiLineString")
+
+    polygon_samples = stf.ST_GeneratePoints(
+        geometry, size.cast("int"), seed.cast("int")
+    )
+
+    # Spark's stable hash supplies one uniformly distributed fraction per
+    # ordinal without evaluating Python code on geometry rows.
+    ordinals = F.sequence(F.lit(0), size.cast("int") - F.lit(1))
+    line_samples = F.transform(
+        ordinals,
+        lambda ordinal: stf.ST_LineInterpolatePoint(
+            geometry,
+            F.pmod(
+                F.xxhash64(seed.cast("long"), ordinal.cast("long")),
+                F.lit(9_007_199_254_740_992),
+            ).cast("double")
+            / F.lit(9_007_199_254_740_992.0),
+        ),
+    )
+    collected_line_samples = stf.ST_Collect(line_samples)
+
+    sampled = (
+        F.when(geometry.isNull() | stf.ST_IsEmpty(geometry), empty_multipoint)
+        .when(~(is_polygonal | is_linear), empty_multipoint)
+        .when(size == 0, empty_multipoint)
+        .when(is_polygonal, polygon_samples)
+        .otherwise(collected_line_samples)
+    )
+
+    return (
+        F.when(
+            size.isNull(),
+            F.raise_error(F.lit("sample size values must be integers")),
+        )
+        .when(
+            size < 0,
+            F.raise_error(F.lit("negative dimensions are not allowed")),
+        )
+        .when(
+            size > 2_147_483_647,
+            F.raise_error(F.lit("sample size must be at most 2147483647")),
+        )
+        .otherwise(sampled)
+    )
+
+
 def _interpret_origin(geometry: PySparkColumn, origin, with_z: bool):
     """Resolve a local affinity origin to distributed coordinate columns."""
     if isinstance(origin, str):
@@ -1503,6 +1610,247 @@ class GeoSeries(GeoFrame, pspd.Series):
             returns_geom=True,
         )
 
+    def sample_points(
+        self,
+        size,
+        method="uniform",
+        seed=None,
+        rng=None,
+        **kwargs,
+    ) -> "GeoSeries":
+        if method != "uniform":
+            raise NotImplementedError(
+                "Sedona only supports method='uniform' for distributed sampling"
+            )
+
+        if seed is not None:
+            warnings.warn(
+                "The 'seed' keyword is deprecated. Use 'rng' instead.",
+                FutureWarning,
+                stacklevel=2,
+            )
+            rng = seed
+
+        stateful_rng = isinstance(rng, (np.random.Generator, np.random.BitGenerator))
+        randomize_per_row = rng is None or stateful_rng
+        engine_seed = _normalize_sample_seed(rng)
+
+        has_crs_metadata, metadata_crs = read_crs_metadata(
+            self._internal.data_fields[0]
+        )
+        fallback_srid = (
+            (metadata_crs.to_epsg() or 0)
+            if has_crs_metadata and metadata_crs is not None
+            else 0
+        )
+
+        def seed_column(row_key: PySparkColumn) -> PySparkColumn:
+            if not randomize_per_row:
+                return F.lit(engine_seed)
+            return (
+                F.pmod(
+                    F.xxhash64(F.lit(engine_seed), row_key.cast("long")),
+                    F.lit(2_147_483_646),
+                )
+                + F.lit(1)
+            ).cast("int")
+
+        def temp_column_name(base: str, reserved_columns) -> str:
+            suffix = 0
+            candidate = f"__sample_points_{base}__"
+            while candidate in reserved_columns:
+                suffix += 1
+                candidate = f"__sample_points_{base}_{suffix}__"
+            reserved_columns.add(candidate)
+            return candidate
+
+        if isinstance(size, PandasOnSparkSeries):
+            size_series = size
+            local_sizes = None
+        elif pd.api.types.is_list_like(size):
+            size_series = None
+            local_sizes = [_normalize_sample_size_scalar(value) for value in size]
+        else:
+            normalized_size = _normalize_sample_size_scalar(size)
+            source_internal = self._internal.resolved_copy
+            source_frame = source_internal.spark_frame
+            geometry_column = source_internal.data_spark_columns[0]
+            row_key = scol_for(source_frame, NATURAL_ORDER_COLUMN_NAME)
+            if stateful_rng:
+                # Natural-order IDs encode Spark partition IDs. Compress them
+                # to row positions so fresh, equivalently seeded stateful RNGs
+                # remain reproducible when the partition layout changes.
+                reserved_columns = set(source_frame.columns)
+                position_col = temp_column_name("position", reserved_columns)
+                source_frame = _attach_ordered_sequence_column(
+                    source_frame,
+                    scol_for(source_frame, NATURAL_ORDER_COLUMN_NAME),
+                    position_col,
+                )
+                row_key = F.col(position_col)
+            spark_expr = _sample_points_expression(
+                geometry_column,
+                F.lit(normalized_size),
+                seed_column(row_key),
+                fallback_srid,
+            )
+            return GeoSeries(
+                self._query_geometry_column(
+                    spark_expr,
+                    source_frame,
+                    returns_geom=True,
+                ).rename("sampled_points")
+            )
+
+        size_type_is_integral = size_series is None or isinstance(
+            size_series.spark.data_type, IntegralType
+        )
+
+        reserved_columns = set(self._internal.spark_frame.columns)
+        if size_series is not None:
+            reserved_columns.update(size_series._internal.spark_frame.columns)
+
+        geometry_value_col = temp_column_name("geometry", reserved_columns)
+        size_value_col = temp_column_name("size", reserved_columns)
+        position_col = temp_column_name("position", reserved_columns)
+        right_present_col = temp_column_name("right_present", reserved_columns)
+
+        if size_series is not None and same_anchor(self, size_series):
+            source_internal = self._internal
+            source_frame = source_internal.spark_frame.select(
+                self.spark.column.alias(geometry_value_col),
+                size_series.spark.column.alias(size_value_col),
+                *[
+                    index_col.alias(index_name)
+                    for index_col, index_name in zip(
+                        source_internal.index_spark_columns,
+                        source_internal.index_spark_column_names,
+                    )
+                ],
+                scol_for(
+                    source_internal.spark_frame,
+                    NATURAL_ORDER_COLUMN_NAME,
+                ).alias(NATURAL_ORDER_COLUMN_NAME),
+            )
+            row_key = F.col(NATURAL_ORDER_COLUMN_NAME)
+            if stateful_rng:
+                # A dense position keeps stateful RNG results stable across
+                # equivalent partition layouts. Both values already share one
+                # plan, so only the source needs to be sequenced.
+                source_frame = _attach_ordered_sequence_column(
+                    source_frame,
+                    row_key,
+                    position_col,
+                )
+                row_key = F.col(position_col)
+
+            size_value = F.col(size_value_col)
+            if not size_type_is_integral:
+                size_value = F.raise_error(
+                    F.lit("sample size values must be integers")
+                ).cast("long")
+            spark_expr = _sample_points_expression(
+                F.col(geometry_value_col),
+                size_value,
+                seed_column(row_key),
+                fallback_srid,
+            )
+            return GeoSeries(
+                self._query_geometry_column(
+                    spark_expr,
+                    source_frame,
+                    returns_geom=True,
+                ).rename("sampled_points")
+            )
+
+        left_frame = self._internal.spark_frame.select(
+            self.spark.column.alias(geometry_value_col),
+            *[
+                index_col.alias(index_name)
+                for index_col, index_name in zip(
+                    self._internal.index_spark_columns,
+                    self._internal.index_spark_column_names,
+                )
+            ],
+            scol_for(self._internal.spark_frame, NATURAL_ORDER_COLUMN_NAME).alias(
+                NATURAL_ORDER_COLUMN_NAME
+            ),
+        )
+        left_frame = _attach_ordered_sequence_column(
+            left_frame,
+            F.col(NATURAL_ORDER_COLUMN_NAME),
+            position_col,
+        )
+        if size_series is not None:
+            right_order_col = temp_column_name("right_order", reserved_columns)
+            right_frame = size_series._internal.spark_frame.select(
+                size_series.spark.column.alias(size_value_col),
+                scol_for(
+                    size_series._internal.spark_frame,
+                    NATURAL_ORDER_COLUMN_NAME,
+                ).alias(right_order_col),
+                F.lit(True).alias(right_present_col),
+            )
+            right_frame = _attach_ordered_sequence_column(
+                right_frame,
+                F.col(right_order_col),
+                position_col,
+            )
+        else:
+            # A local sequence already has a driver-side position. Keep those
+            # positions as Spark rows instead of sorting a temporary
+            # pandas-on-Spark Series or embedding a large Catalyst literal.
+            right_schema = StructType(
+                [
+                    StructField(position_col, LongType(), nullable=False),
+                    StructField(size_value_col, LongType(), nullable=False),
+                    StructField(right_present_col, BooleanType(), nullable=False),
+                ]
+            )
+            # Materialize the validated values once before constructing the
+            # explicitly typed Spark DataFrame, including for an empty input.
+            right_rows = [
+                (position, size_value, True)
+                for position, size_value in enumerate(local_sizes)
+            ]
+            right_frame = self._internal.spark_frame.sparkSession.createDataFrame(
+                right_rows,
+                schema=right_schema,
+            )
+        # GeoPandas zips geometries with array-like sizes: missing sizes are an
+        # error, while extra sizes are ignored (including for an empty source).
+        aligned_frame = left_frame.join(
+            right_frame, on=position_col, how="left"
+        ).orderBy(position_col)
+
+        size_value = F.col(size_value_col)
+        if not size_type_is_integral:
+            # Keep validation lazy so an empty source follows GeoPandas and
+            # returns empty even when the unused size Series has another dtype.
+            size_value = F.raise_error(
+                F.lit("sample size values must be integers")
+            ).cast("long")
+
+        spark_expr = _sample_points_expression(
+            F.col(geometry_value_col),
+            size_value,
+            seed_column(F.col(position_col)),
+            fallback_srid,
+        )
+        spark_expr = F.when(
+            F.col(right_present_col).isNull(),
+            F.raise_error(
+                F.lit("Length of sample sizes does not match length of GeoSeries")
+            ),
+        ).otherwise(spark_expr)
+        return GeoSeries(
+            self._query_geometry_column(
+                spark_expr,
+                aligned_frame,
+                returns_geom=True,
+            ).rename("sampled_points")
+        )
+
     def segmentize(self, max_segment_length):
         other_series, extended = self._make_series_of_val(max_segment_length)
         align = not extended
@@ -3150,41 +3498,21 @@ class GeoSeries(GeoFrame, pspd.Series):
         2    POINT (-3 1.5)
         dtype: geometry
         """
-        from pyspark.sql.types import StructType, StructField, DoubleType
-
-        schema = StructType(
-            [StructField("x", DoubleType(), True), StructField("y", DoubleType(), True)]
-        )
-
-        # Spark doesn't automatically cast ints to floats for us
-        x = [float(num) for num in x]
-        y = [float(num) for num in y]
-        z = [float(num) for num in z] if z else None
-
-        if z:
-            data = list(zip(x, y, z))
-            select = stc.ST_PointZ(F.col("x"), F.col("y"), F.col("z"))
-            schema.add(StructField("z", DoubleType(), True))
-        else:
-            data = list(zip(x, y))
-            select = stc.ST_Point(F.col("x"), F.col("y"))
-
-        geoseries = cls._create_from_select(
-            select,
-            data,
-            schema,
-            index,
-            crs,
-            **kwargs,
+        from sedona.spark.geopandas.array import _points_from_xy
+
+        unsupported = set(kwargs) - {"name"}
+        if unsupported:
+            arguments = ", ".join(sorted(unsupported))
+            raise TypeError(f"Unsupported GeoSeries.from_xy arguments: {arguments}")
+        return _points_from_xy(
+            x,
+            y,
+            z=z,
+            crs=crs,
+            index=index,
+            name=kwargs.get("name"),
         )
 
-        if crs:
-            from pyproj import CRS
-
-            geoseries.crs = CRS.from_user_input(crs).to_epsg()
-
-        return geoseries
-
     @classmethod
     def from_shapely(
         cls, data, index=None, crs: Union[Any, None] = None, **kwargs
diff --git a/python/tests/geopandas/test_array.py b/python/tests/geopandas/test_array.py
new file mode 100644
index 00000000000..c7e8ee38f60
--- /dev/null
+++ b/python/tests/geopandas/test_array.py
@@ -0,0 +1,221 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import geopandas as gpd
+import numpy as np
+import pandas as pd
+import pyspark.pandas as ps
+import pytest
+from geopandas.testing import assert_geoseries_equal
+
+import sedona.spark.geopandas as sgpd
+from sedona.spark.geopandas import GeoSeries
+from sedona.spark.sql import st_functions as stf
+from tests.geopandas.test_geopandas_base import TestGeopandasBase
+
+
+def _assert_geoseries_equal_with_nan_xy(actual, expected, nan_xy):
+    """Compare NaN x/y coordinates without relying on undefined GEOS equality."""
+    assert actual.crs == expected.crs
+    nan_xy = np.asarray(nan_xy, dtype=bool)
+    nan_positions = np.flatnonzero(nan_xy)
+    regular_positions = np.flatnonzero(~nan_xy)
+    pd.testing.assert_series_equal(
+        actual.to_wkt().iloc[nan_positions],
+        expected.to_wkt().iloc[nan_positions],
+    )
+    if regular_positions.size:
+        assert_geoseries_equal(
+            actual.iloc[regular_positions],
+            expected.iloc[regular_positions],
+        )
+
+
+class TestPointsFromXY(TestGeopandasBase):
+    def test_points_from_xy_local_matches_geopandas(self):
+        index = pd.Index(["first", "first", "third"], name="feature")
+        x = pd.Series([1, 2, -3.5], index=index, name="longitude")
+        y = pd.Series([4, 5, 6.5], index=index, name="latitude")
+
+        result = sgpd.points_from_xy(x, y, crs="EPSG:4326")
+        expected = gpd.GeoSeries(
+            gpd.points_from_xy(x, y, crs="EPSG:4326"),
+            index=index,
+            crs="EPSG:4326",
+        )
+
+        assert result.name is None
+        assert result.crs == "EPSG:4326"
+        assert_geoseries_equal(result.to_geopandas(), expected)
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids} == {4326}
+
+    def test_points_from_xy_local_3d_broadcast_and_empty(self):
+        result = sgpd.points_from_xy([1, 2], [3], [4, 5])
+        # GeoPandas 0.13 does not broadcast its own inputs, so construct the
+        # equivalent oracle from explicitly expanded coordinates.
+        expected = gpd.GeoSeries(gpd.points_from_xy([1, 2], [3, 3], [4, 5]))
+        assert_geoseries_equal(result.to_geopandas(), expected)
+
+        scalar_result = sgpd.points_from_xy(1, [2, 3], 4)
+        scalar_expected = gpd.GeoSeries(gpd.points_from_xy([1, 1], [2, 3], [4, 4]))
+        assert_geoseries_equal(scalar_result.to_geopandas(), scalar_expected)
+
+        empty = sgpd.points_from_xy([], [], crs="EPSG:3857")
+        assert len(empty) == 0
+        assert empty.crs == "EPSG:3857"
+
+    def test_points_from_xy_distributed_preserves_multiindex_and_plan(self):
+        index = pd.MultiIndex.from_tuples(
+            [("a", 1), ("a", 1), ("b", 2)],
+            names=["group", "position"],
+        )
+        pdf = pd.DataFrame(
+            {
+                "x": [1.0, np.nan, 3.0],
+                "y": [4.0, 5.0, 6.0],
+                "z": [7.0, 8.0, np.nan],
+            },
+            index=index,
+        )
+        psdf = ps.from_pandas(pdf)
+
+        result = sgpd.points_from_xy(
+            psdf["x"],
+            psdf["y"],
+            psdf["z"],
+            crs="EPSG:4979",
+        )
+        expected = gpd.GeoSeries(
+            gpd.points_from_xy(pdf["x"], pdf["y"], pdf["z"], crs="EPSG:4979"),
+            index=index,
+            crs="EPSG:4979",
+        )
+
+        assert result.crs == "EPSG:4979"
+        nan_xy = pdf[["x", "y"]].isna().any(axis=1).to_numpy()
+        _assert_geoseries_equal_with_nan_xy(
+            result.to_geopandas(),
+            expected,
+            nan_xy,
+        )
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids} == {4979}
+
+        spark_frame = result._internal.spark_frame
+        if hasattr(spark_frame, "_jdf"):
+            plan = spark_frame._jdf.queryExecution().executedPlan().toString()
+            assert "BatchEvalPython" not in plan
+            assert "ArrowEvalPython" not in plan
+            assert "PythonUDF" not in plan
+
+        scalar_result = sgpd.points_from_xy(psdf["x"], 10, crs="EPSG:4326")
+        scalar_expected = gpd.GeoSeries(
+            gpd.points_from_xy(pdf["x"], [10] * len(pdf), crs="EPSG:4326"),
+            index=index,
+            crs="EPSG:4326",
+        )
+        _assert_geoseries_equal_with_nan_xy(
+            scalar_result.to_geopandas(),
+            scalar_expected,
+            pdf["x"].isna().to_numpy(),
+        )
+
+    def test_points_from_xy_preserves_pending_expressions(self):
+        frame = ps.DataFrame(
+            {
+                "x": [1.0, 2.0],
+                "y": [3.0, 4.0],
+                "value": [10, 11],
+            }
+        )
+        frame["computed_index"] = frame["value"] + 100
+        frame = frame.set_index("computed_index")
+
+        result = sgpd.points_from_xy(frame["x"] * 2, frame["y"] + 0.5)
+        actual = result.to_geopandas()
+
+        assert actual.index.equals(pd.Index([110, 111], name="computed_index"))
+        assert actual.to_wkt().tolist() == [
+            "POINT (2 3.5)",
+            "POINT (4 4.5)",
+        ]
+
+    def test_points_from_xy_null_is_nan_coordinate_not_missing_geometry(self):
+        psdf = self.spark.createDataFrame(
+            [(0, None, 2.0), (1, 3.0, None), (2, None, None)],
+            "id long, x double, y double",
+        ).pandas_api(index_col="id")
+
+        result = sgpd.points_from_xy(psdf["x"], psdf["y"])
+
+        assert result.isna().to_pandas().tolist() == [False, False, False]
+        expected = gpd.GeoSeries(
+            gpd.points_from_xy([None, 3.0, None], [2.0, None, None]),
+            index=pd.Index([0, 1, 2], name="id"),
+        )
+        _assert_geoseries_equal_with_nan_xy(
+            result.to_geopandas(),
+            expected,
+            np.ones(len(expected), dtype=bool),
+        )
+
+    def test_geoseries_from_xy_delegates_to_distributed_constructor(self):
+        result = GeoSeries.from_xy(
+            [1, 2],
+            [3, 4],
+            index=pd.Index([10, 20], name="id"),
+            crs="EPSG:4326",
+            name="location",
+        )
+        expected = gpd.GeoSeries(
+            gpd.points_from_xy([1, 2], [3, 4], crs="EPSG:4326"),
+            index=pd.Index([10, 20], name="id"),
+            crs="EPSG:4326",
+            name="location",
+        )
+
+        assert_geoseries_equal(result.to_geopandas(), expected)
+
+    def test_points_from_xy_validates_unsupported_inputs(self):
+        with pytest.raises(TypeError, match="at least one coordinate"):
+            sgpd.points_from_xy(1, 2)
+        with pytest.raises(ValueError, match="broadcast-compatible"):
+            sgpd.points_from_xy([1, 2], [3, 4, 5])
+
+        frame = ps.DataFrame({"x": [1, 2], "y": [3, 4]})
+        with pytest.raises(TypeError, match="numeric scalar"):
+            sgpd.points_from_xy(frame["x"], [3, 4])
+        string_frame = ps.DataFrame({"x": [1, 2], "y": ["a", "b"]})
+        with pytest.raises(
+            TypeError, match="y must be a numeric pandas-on-Spark Series"
+        ):
+            sgpd.points_from_xy(string_frame["x"], string_frame["y"])
+        with pytest.raises(
+            TypeError, match="x must be a numeric pandas-on-Spark Series"
+        ):
+            sgpd.points_from_xy(string_frame["y"], string_frame["x"])
+
+        other_frame = ps.DataFrame({"y": [3, 4]})
+        with pytest.raises(ValueError, match="same distributed frame"):
+            sgpd.points_from_xy(frame["x"], other_frame["y"])
+        with pytest.raises(TypeError, match="index cannot be supplied"):
+            GeoSeries.from_xy(frame["x"], frame["y"], index=[0, 1])
diff --git a/python/tests/geopandas/test_geodataframe.py b/python/tests/geopandas/test_geodataframe.py
index fddd678eef3..e7c51eb7772 100644
--- a/python/tests/geopandas/test_geodataframe.py
+++ b/python/tests/geopandas/test_geodataframe.py
@@ -167,6 +167,116 @@ class TestGeoDataFrame(TestGeopandasBase):
         ps_df = ps.DataFrame(data, index=index)
         assert_frame_equal(result.to_pandas(), ps_df.to_pandas())
 
+    def test_to_wkt_and_wkb_distributed(self):
+        index = pd.MultiIndex.from_tuples(
+            [("group-a", 1), ("group-a", 1), ("group-b", 2)],
+            names=["group", "position"],
+        )
+        primary = ("geometry", "primary")
+        secondary = ("geometry", "secondary")
+        value = ("attribute", "value")
+        gpd_df = gpd.GeoDataFrame(
+            {
+                "primary": gpd.GeoSeries(
+                    [Point(0, 0), Polygon(), None],
+                    index=index,
+                    crs="EPSG:4326",
+                ),
+                "secondary": gpd.GeoSeries(
+                    [LineString([(0, 0), (1, 1)]), None, Point(2, 2)],
+                    index=index,
+                    crs="EPSG:4326",
+                ),
+                "value": [10, 11, 12],
+            },
+            index=index,
+            geometry="primary",
+            crs="EPSG:4326",
+        )
+        gpd_df.columns = pd.MultiIndex.from_tuples(
+            [primary, secondary, value], names=["kind", "name"]
+        )
+        gpd_df = gpd_df.set_geometry(primary)
+        sgpd_df = GeoDataFrame(gpd_df)
+
+        wkt_result = sgpd_df.to_wkt()
+        assert isinstance(wkt_result, ps.DataFrame)
+        assert not isinstance(wkt_result, GeoDataFrame)
+        assert_frame_equal(
+            wkt_result.to_pandas(),
+            pd.DataFrame(
+                {
+                    primary: ["POINT (0 0)", "POLYGON EMPTY", None],
+                    secondary: ["LINESTRING (0 0, 1 1)", None, "POINT (2 2)"],
+                    value: [10, 11, 12],
+                },
+                index=index,
+            ).rename_axis(columns=["kind", "name"]),
+        )
+
+        wkb_result = sgpd_df.to_wkb()
+        hex_result = sgpd_df.to_wkb(hex=True)
+        for result in (wkt_result, wkb_result, hex_result):
+            assert isinstance(result, ps.DataFrame)
+            assert not isinstance(result, GeoDataFrame)
+            assert "geometry" not in result.dtypes.astype(str).tolist()
+        for result in (wkb_result, hex_result):
+            result_pdf = result.to_pandas()
+            assert result_pdf.index.equals(index)
+            assert result_pdf.columns.equals(gpd_df.columns)
+            assert result_pdf[value].tolist() == [10, 11, 12]
+        assert sgpd_df.crs == "EPSG:4326"
+
+        binary_pdf = wkb_result.to_pandas()
+        hex_pdf = hex_result.to_pandas()
+        for column in (primary, secondary):
+            for binary, hexadecimal in zip(binary_pdf[column], hex_pdf[column]):
+                if binary is None:
+                    assert hexadecimal is None
+                else:
+                    assert hexadecimal == binary.hex().upper()
+
+        for result in (wkt_result, wkb_result, hex_result):
+            spark_frame = result._internal.spark_frame
+            if hasattr(spark_frame, "_jdf"):
+                plan = spark_frame._jdf.queryExecution().executedPlan().toString()
+                assert "BatchEvalPython" not in plan
+                assert "ArrowEvalPython" not in plan
+
+        computed_index = GeoDataFrame(
+            {
+                "geometry": [Point(0, 0), Point(1, 1)],
+                "value": [10, 11],
+            }
+        )
+        computed_index["computed_index"] = computed_index["value"] + 100
+        computed_index = GeoDataFrame(computed_index.set_index("computed_index"))
+        computed_wkt = computed_index.to_wkt().to_pandas()
+        computed_wkb = computed_index.to_wkb().to_pandas()
+        expected_index = pd.Index([110, 111], name="computed_index")
+        assert computed_wkt.index.equals(expected_index)
+        assert computed_wkb.index.equals(expected_index)
+        assert computed_wkt["geometry"].tolist() == [
+            "POINT (0 0)",
+            "POINT (1 1)",
+        ]
+        assert [
+            shapely.from_wkb(bytes(value))
+            for value in computed_wkb["geometry"].tolist()
+        ] == [Point(0, 0), Point(1, 1)]
+
+    def test_geometry_serialization_kwargs_are_explicitly_unsupported(self):
+        gdf = GeoDataFrame({"geometry": [Point(1.234567890123, 2.345678901234)]})
+
+        assert gdf.to_wkt().to_pandas()["geometry"].tolist() == [
+            "POINT (1.234567890123 2.345678901234)"
+        ]
+
+        with pytest.raises(NotImplementedError, match="rounding_precision"):
+            gdf.to_wkt(rounding_precision=2)
+        with pytest.raises(NotImplementedError, match="byte_order"):
+            gdf.to_wkb(byte_order=0)
+
     def test_getitem(self):
         geoms = [Point(x, x) for x in range(3)]
         ids = [1, 2, 3]
diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py
index 4dadb448cad..2cb0acccfde 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -22,6 +22,9 @@ import pandas as pd
 import geopandas as gpd
 import pyspark.pandas as ps
 import sedona.spark.geopandas as sgpd
+from pyspark.pandas.internal import InternalFrame, NATURAL_ORDER_COLUMN_NAME
+from pyspark.pandas.utils import scol_for
+from pyspark.sql import functions as F
 from sedona.spark.geopandas import GeoSeries, GeoDataFrame
 from sedona.spark.geopandas.geoseries import _to_bool
 from sedona.spark.sql import st_functions as stf
@@ -2471,6 +2474,271 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = s.to_geoframe().reverse()
         self.check_sgpd_equals_gpd(df_result, expected)
 
+    def test_sample_points(self):
+        polygon = box(0, 0, 2, 2)
+        multipolygon = MultiPolygon([box(4, 0, 5, 1), box(7, 0, 9, 2)])
+        line = LineString([(0, 0), (2, 0), (2, 2)])
+        multiline = MultiLineString([[(0, 4), (1, 4)], [(3, 4), (3, 7)]])
+        geometries = [
+            polygon,
+            multipolygon,
+            line,
+            multiline,
+            Point(1, 1),
+            MultiPoint([(0, 0), (1, 1)]),
+            GeometryCollection([Point(0, 0)]),
+            Polygon(),
+            LineString(),
+            None,
+        ]
+        index = pd.Index(
+            [
+                "area",
+                "area",
+                "line",
+                "line",
+                "point",
+                "points",
+                "gc",
+                "ep",
+                "el",
+                "null",
+            ],
+            name="feature_id",
+        )
+        source = GeoSeries(geometries, index=index, crs="EPSG:3857")
+
+        result = source.sample_points(4, rng=0)
+        actual = result.to_geopandas()
+        repeated = source.sample_points(4, rng=0).to_geopandas()
+
+        assert isinstance(result, GeoSeries)
+        assert result.name == "sampled_points"
+        assert result.crs == source.crs
+        assert actual.index.equals(index)
+        assert [geometry.wkb for geometry in actual] == [
+            geometry.wkb for geometry in repeated
+        ]
+        for source_geometry, sampled in zip(geometries[:4], actual.iloc[:4]):
+            assert sampled.geom_type == "MultiPoint"
+            assert len(sampled.geoms) == 4
+            assert all(source_geometry.covers(point) for point in sampled.geoms)
+        for sampled in actual.iloc[4:]:
+            assert sampled.geom_type == "MultiPoint"
+            assert sampled.is_empty
+
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids} == {3857}
+
+        if hasattr(result._internal.spark_frame, "_jdf"):
+            plan = (
+                result._internal.spark_frame._jdf.queryExecution()
+                .executedPlan()
+                .toString()
+            )
+            assert "BatchEvalPython" not in plan
+            assert "ArrowEvalPython" not in plan
+            assert "PythonUDF" not in plan
+
+        frame_result = source.to_geoframe().sample_points(4, rng=0)
+        assert isinstance(frame_result, GeoSeries)
+        assert frame_result.name == "sampled_points"
+        assert frame_result.crs == source.crs
+
+        zero = GeoSeries([polygon, line, Point(0, 0)]).sample_points(0, rng=1)
+        zero_types = [geometry.geom_type for geometry in zero.to_geopandas()]
+        assert zero_types == ["MultiPoint", "MultiPoint", "MultiPoint"]
+
+        one = GeoSeries([polygon, line, Point(0, 0)]).sample_points(1, rng=1)
+        one_types = [geometry.geom_type for geometry in one.to_geopandas()]
+        assert one_types == ["MultiPoint", "MultiPoint", "MultiPoint"]
+
+        degenerate_lines = GeoSeries(
+            [
+                LineString([(0, 0), (0, 0)]),
+                MultiLineString([[(1, 1), (1, 1)], [(1, 1), (1, 1)]]),
+            ]
+        ).sample_points(4, rng=1)
+        degenerate_actual = degenerate_lines.to_geopandas()
+        assert [geometry.geom_type for geometry in degenerate_actual] == [
+            "MultiPoint",
+            "MultiPoint",
+        ]
+        assert all(
+            len(geometry.geoms) == 4
+            and all(point.equals(Point(i, i)) for point in geometry.geoms)
+            for i, geometry in enumerate(degenerate_actual)
+        )
+
+        multi_index = pd.MultiIndex.from_tuples(
+            [("a", 1), ("a", 2), ("b", 1), ("b", 2)],
+            names=["group", "position"],
+        )
+        distributed_source = GeoSeries(
+            [polygon, multipolygon, line, multiline],
+            index=multi_index,
+            crs="EPSG:3857",
+        )
+        distributed_result = distributed_source.sample_points(
+            ps.Series([1, 2, 3, 4]), rng=7
+        ).to_geopandas()
+        assert distributed_result.index.equals(multi_index)
+        assert [
+            1 if geometry.geom_type == "Point" else len(geometry.geoms)
+            for geometry in distributed_result
+        ] == [1, 2, 3, 4]
+
+        extra_sizes = distributed_source.sample_points(
+            [1, 2, 3, 4, 99], rng=7
+        ).to_geopandas()
+        assert [
+            1 if geometry.geom_type == "Point" else len(geometry.geoms)
+            for geometry in extra_sizes
+        ] == [1, 2, 3, 4]
+
+        same_anchor_index = pd.Index(
+            ["duplicate", "duplicate", "other", "other"],
+            name="feature_id",
+        )
+        same_anchor_frame = GeoDataFrame(
+            gpd.GeoDataFrame(
+                {
+                    "geometry": [polygon, multipolygon, line, multiline],
+                    "size": [1, 2, 3, 4],
+                },
+                index=same_anchor_index,
+                crs="EPSG:3857",
+            )
+        )
+        same_anchor_result = same_anchor_frame.geometry.sample_points(
+            same_anchor_frame["size"], rng=7
+        )
+        same_anchor_actual = same_anchor_result.to_geopandas()
+        assert same_anchor_actual.index.equals(same_anchor_index)
+        assert [
+            1 if geometry.geom_type == "Point" else len(geometry.geoms)
+            for geometry in same_anchor_actual
+        ] == [1, 2, 3, 4]
+        if hasattr(same_anchor_result._internal.spark_frame, "_jdf"):
+            plan = (
+                same_anchor_result._internal.spark_frame._jdf.queryExecution()
+                .optimizedPlan()
+                .toString()
+            )
+            assert "Join" not in plan
+            assert "AttachDistributedSequence" not in plan
+
+        # Like GeoPandas, an integer seed restarts the same random stream for
+        # every row, so identical geometries receive identical samples.
+        identical = GeoSeries(
+            [line, line],
+            index=pd.Index(["duplicate", "duplicate"], name="feature_id"),
+        ).sample_points(4, rng=7)
+        identical_actual = identical.to_geopandas()
+        assert identical_actual.iloc[0].wkb == identical_actual.iloc[1].wkb
+
+        for sizes in ([], [1, 2, 3]):
+            empty_result = GeoSeries([], crs="EPSG:3857").sample_points(sizes, rng=7)
+            assert len(empty_result) == 0
+            assert empty_result.name == "sampled_points"
+            assert empty_result.crs == "EPSG:3857"
+
+        empty_float_sizes = GeoSeries([], crs="EPSG:3857").sample_points(
+            ps.Series([], dtype=float), rng=7
+        )
+        assert len(empty_float_sizes) == 0
+        assert empty_float_sizes.name == "sampled_points"
+        assert empty_float_sizes.crs == "EPSG:3857"
+
+        with pytest.raises(TypeError):
+            source.sample_points(True)
+        with pytest.raises(TypeError):
+            source.sample_points(1.5)
+        with pytest.raises(ValueError):
+            source.sample_points(-1)
+        with pytest.raises(NotImplementedError):
+            source.sample_points(1, method="cluster_poisson")
+        with pytest.raises(Exception, match="sample size values must be integers"):
+            source.sample_points(ps.Series([1.0] * len(source))).to_geopandas()
+        for short_sizes in ([], [1]):
+            with pytest.raises(Exception, match="Length of sample sizes"):
+                source.sample_points(short_sizes, rng=1).to_geopandas()
+        with pytest.warns(FutureWarning, match="'seed' keyword is deprecated"):
+            source.sample_points(1, seed=1)
+
+    def test_sample_points_stateful_rng_is_partition_stable(self):
+        line = LineString([(0, 0), (2, 0), (2, 2)])
+        identical_source = GeoSeries(
+            [line, line, line, line],
+            index=pd.Index(["duplicate"] * 4, name="feature_id"),
+        )
+        test_position_col = "__sample_points_test_position__"
+        positioned_frame = InternalFrame.attach_distributed_sequence_column(
+            identical_source._internal.spark_frame.orderBy(NATURAL_ORDER_COLUMN_NAME),
+            test_position_col,
+        )
+        ordered_rows = positioned_frame.select(
+            F.col(test_position_col).alias("position"),
+            scol_for(
+                positioned_frame,
+                identical_source._internal.index_spark_column_names[0],
+            ).alias("feature_id"),
+            scol_for(
+                positioned_frame,
+                identical_source._internal.data_spark_column_names[0],
+            ).alias("geometry"),
+        )
+
+        # These encodings have the same row order but mimic natural-order IDs
+        # generated with one partition and with a boundary before position 2.
+        natural_order_encodings = [
+            F.col("position"),
+            F.when(F.col("position") < 2, F.col("position")).otherwise(
+                F.lit(1 << 33) + F.col("position") - F.lit(2)
+            ),
+        ]
+
+        def source_with_natural_order(encoding):
+            spark_frame = ordered_rows.select(
+                F.col("feature_id"),
+                F.col("geometry"),
+                encoding.cast("long").alias(NATURAL_ORDER_COLUMN_NAME),
+            )
+            return GeoSeries(spark_frame.pandas_api(index_col="feature_id")["geometry"])
+
+        for rng_factory in (
+            lambda: np.random.default_rng(0),
+            lambda: np.random.PCG64(0),
+        ):
+            stateful_results = [
+                source_with_natural_order(encoding).sample_points(4, rng=rng_factory())
+                for encoding in natural_order_encodings
+            ]
+            assert (
+                stateful_results[0]
+                .to_geopandas()
+                .index.equals(pd.Index(["duplicate"] * 4, name="feature_id"))
+            )
+            ordered_wkb = [
+                [
+                    row.geometry.wkb
+                    for row in result._internal.spark_frame.select(
+                        result.spark.column.alias("geometry"),
+                        scol_for(
+                            result._internal.spark_frame,
+                            NATURAL_ORDER_COLUMN_NAME,
+                        ).alias("natural_order"),
+                    )
+                    .orderBy("natural_order")
+                    .collect()
+                ]
+                for result in stateful_results
+            ]
+            assert ordered_wkb[0] == ordered_wkb[1]
+            assert len(set(ordered_wkb[0])) == len(ordered_wkb[0])
+
     def test_segmentize(self):
         s = GeoSeries(
             [
diff --git a/python/tests/geopandas/test_match_geopandas_dataframe.py b/python/tests/geopandas/test_match_geopandas_dataframe.py
index 3b0b8eff42d..5659ddf54c8 100644
--- a/python/tests/geopandas/test_match_geopandas_dataframe.py
+++ b/python/tests/geopandas/test_match_geopandas_dataframe.py
@@ -33,8 +33,10 @@ from shapely.geometry import (
 from packaging.version import parse as parse_version
 from sedona.spark.geopandas import GeoDataFrame, GeoSeries
 import geopandas as gpd
+import pandas as pd
 from tests.geopandas.test_geopandas_base import TestGeopandasBase
 import pyspark.pandas as ps
+from pandas.testing import assert_frame_equal
 
 
 @pytest.mark.skipif(
@@ -225,6 +227,45 @@ class TestMatchGeopandasDataFrame(TestGeopandasBase):
             gpd_result = gpd.GeoDataFrame(data, crs="EPSG:3857").to_json(**kwargs)
             assert sgpd_result == gpd_result
 
+    def test_to_wkt_and_to_wkb(self):
+        index = pd.MultiIndex.from_tuples(
+            [("a", 1), ("a", 1), ("b", 2)],
+            names=["group", "position"],
+        )
+        primary = ("geometry", "primary")
+        secondary = ("geometry", "secondary")
+        value = ("attribute", "value")
+        gpd_df = gpd.GeoDataFrame(
+            {
+                "primary": gpd.GeoSeries(
+                    [Point(0, 0), Polygon(), None],
+                    index=index,
+                    crs="EPSG:4326",
+                ),
+                "secondary": gpd.GeoSeries(
+                    [LineString([(0, 0), (1, 1)]), None, Point(2, 2)],
+                    index=index,
+                    crs="EPSG:4326",
+                ),
+                "value": [1, 2, 3],
+            },
+            index=index,
+            geometry="primary",
+            crs="EPSG:4326",
+        )
+        gpd_df.columns = pd.MultiIndex.from_tuples(
+            [primary, secondary, value], names=["kind", "name"]
+        )
+        gpd_df = gpd_df.set_geometry(primary)
+        sgpd_df = GeoDataFrame(gpd_df)
+
+        assert_frame_equal(sgpd_df.to_wkt().to_pandas(), gpd_df.to_wkt())
+        assert_frame_equal(sgpd_df.to_wkb().to_pandas(), gpd_df.to_wkb())
+        assert_frame_equal(
+            sgpd_df.to_wkb(hex=True).to_pandas(),
+            gpd_df.to_wkb(hex=True),
+        )
+
     def test_from_arrow(self):
         if parse_version(gpd.__version__) < parse_version("1.0.0"):
             return
diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py
index 085fedca373..4fa9f81d680 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -1276,6 +1276,79 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             gpd_result = gpd.GeoSeries(geom).reverse()
             self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
 
+    @pytest.mark.skipif(
+        parse_version(gpd.__version__) < parse_version("0.14.0"),
+        reason="geopandas sample_points requires version 0.14.0 or higher",
+    )
+    def test_sample_points(self):
+        geometries = [
+            Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]),
+            MultiPolygon(
+                [
+                    Polygon([(4, 0), (5, 0), (5, 1), (4, 1)]),
+                    Polygon([(7, 0), (9, 0), (9, 2), (7, 2)]),
+                ]
+            ),
+            LineString([(0, 4), (2, 4), (2, 6)]),
+            MultiLineString([[(0, 8), (1, 8)], [(3, 8), (3, 11)]]),
+            Point(0, 0),
+            GeometryCollection([LineString([(0, 0), (1, 1)])]),
+            Polygon(),
+            None,
+        ]
+        index = pd.Index(
+            [
+                "polygon",
+                "multipolygon",
+                "line",
+                "multiline",
+                "point",
+                "gc",
+                "empty",
+                "null",
+            ],
+            name="feature_id",
+        )
+        sgpd_source = GeoSeries(geometries, index=index, crs="EPSG:3857")
+        gpd_source = gpd.GeoSeries(geometries, index=index, crs="EPSG:3857")
+
+        sgpd_result = sgpd_source.sample_points(3, rng=9).to_geopandas()
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", UserWarning)
+            gpd_result = gpd_source.sample_points(3, rng=9)
+
+        assert sgpd_result.name == gpd_result.name == "sampled_points"
+        assert sgpd_result.crs == gpd_result.crs
+        assert sgpd_result.index.equals(gpd_result.index)
+        assert [geometry.geom_type for geometry in sgpd_result] == [
+            geometry.geom_type for geometry in gpd_result
+        ]
+        assert [len(geometry.geoms) for geometry in sgpd_result] == [
+            len(geometry.geoms) for geometry in gpd_result
+        ]
+        for source_geometry, sampled in zip(geometries[:4], sgpd_result.iloc[:4]):
+            assert all(source_geometry.covers(point) for point in sampled.geoms)
+
+        sizes = [1, 2, 3, 4, 5, 6, 7, 8]
+        sgpd_sized = sgpd_source.sample_points(sizes, rng=9).to_geopandas()
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", UserWarning)
+            gpd_sized = gpd_source.sample_points(sizes, rng=9)
+        expected_types = [geometry.geom_type for geometry in gpd_sized]
+        if parse_version(gpd.__version__) < parse_version("1.1.4"):
+            # GeoPandas 1.1.4 stopped applying union_all() to samples and
+            # standardized every supported result, including size 1, as a
+            # MultiPoint. Sedona follows that current return contract.
+            expected_types[0] = "MultiPoint"
+        assert [geometry.geom_type for geometry in sgpd_sized] == expected_types
+        assert [
+            (1 if geometry.geom_type == "Point" else len(geometry.geoms))
+            for geometry in sgpd_sized
+        ] == [
+            (1 if geometry.geom_type == "Point" else len(geometry.geoms))
+            for geometry in gpd_sized
+        ]
+
     @pytest.mark.skipif(
         parse_version(gpd.__version__) < parse_version("0.14.0"),
         reason="geopandas segmentize requires version 0.14.0 or higher",