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/04/28 22:39:18 UTC

(sedona) branch master updated: [GH-2830] Adds Geography dual-dispatch to ST_Centroid (#2852)

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 df3f740855 [GH-2830] Adds Geography dual-dispatch to ST_Centroid (#2852)
df3f740855 is described below

commit df3f740855f439da7f030b35401e45b7aea8949a
Author: Feng Zhang <fe...@apache.org>
AuthorDate: Tue Apr 28 15:39:13 2026 -0700

    [GH-2830] Adds Geography dual-dispatch to ST_Centroid (#2852)
    
    Co-authored-by: Jia Yu <ji...@apache.org>
---
 .../apache/sedona/common/geography/Functions.java  | 80 +++++++++++++++++++
 .../sedona/common/Geography/FunctionTest.java      | 93 ++++++++++++++++++++++
 docs/api/sql/geography/Geography-Functions.md      |  1 +
 .../geography/Geography-Functions/ST_Centroid.md   | 56 +++++++++++++
 .../sql/sedona_sql/expressions/Functions.scala     |  4 +-
 .../sql/geography/GeographyFunctionTest.scala      | 17 +++-
 6 files changed, 248 insertions(+), 3 deletions(-)

diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
index 0e3a7279d5..125552ab55 100644
--- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
@@ -85,6 +85,86 @@ public class Functions {
     return toJTS(g).getNumPoints();
   }
 
+  /**
+   * Returns the spherical centroid of a geography as a {@link Geography} point. The centroid is
+   * computed on the sphere using S2 area- and length-weighting:
+   *
+   * <ul>
+   *   <li>Polygon / MultiPolygon: area-weighted centroid via {@link S2Polygon#getCentroid()}.
+   *   <li>LineString / MultiLineString: length-weighted centroid via {@link
+   *       S2Polyline#getCentroid()}.
+   *   <li>Point / MultiPoint: mean of the unit vectors.
+   *   <li>GeographyCollection: weighted sum of the children's S2 centroids.
+   * </ul>
+   *
+   * <p>Unlike a planar (lon/lat) centroid, this answer is correct for antimeridian-crossing and
+   * polar geographies. As with JTS for non-convex shapes, the centroid may lie outside the input
+   * geometry. Returns {@code null} if the input is {@code null} or if the centroid is undefined
+   * (e.g. an empty geometry, or antipodal points whose unit vectors cancel).
+   */
+  public static Geography centroid(Geography g) {
+    if (g == null) return null;
+    Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g;
+    S2Point weighted = sphericalCentroid(typed);
+    if (weighted == null) return null;
+    // S2 returns area- (or length-) weighted centroids; project back to the sphere.
+    double norm = weighted.norm();
+    if (!(norm > 0.0)) return null; // zero or NaN — centroid undefined
+    S2Point unit = S2Point.normalize(weighted);
+    SinglePointGeography point = new SinglePointGeography(unit);
+    point.setSRID(g.getSRID());
+    return WKBGeography.fromS2Geography(point);
+  }
+
+  /**
+   * Returns the S2-weighted centroid of {@code g} (area-weighted for polygons, length-weighted for
+   * polylines, sum of unit vectors for points). Result is {@code null} when no centroid
+   * contribution exists (empty input, unsupported kind).
+   */
+  private static S2Point sphericalCentroid(Geography g) {
+    if (g instanceof PointGeography) {
+      List<S2Point> pts = ((PointGeography) g).getPoints();
+      if (pts == null || pts.isEmpty()) return null;
+      S2Point sum = pts.get(0);
+      for (int i = 1; i < pts.size(); i++) {
+        sum = S2Point.add(sum, pts.get(i));
+      }
+      return sum;
+    }
+    if (g instanceof PolylineGeography) {
+      S2Point sum = null;
+      for (S2Polyline p : ((PolylineGeography) g).getPolylines()) {
+        sum = addOrInit(sum, p.getCentroid());
+      }
+      return sum;
+    }
+    if (g instanceof PolygonGeography) {
+      return ((PolygonGeography) g).polygon.getCentroid();
+    }
+    if (g instanceof MultiPolygonGeography) {
+      S2Point sum = null;
+      for (Geography feature : ((MultiPolygonGeography) g).getFeatures()) {
+        if (feature instanceof PolygonGeography) {
+          sum = addOrInit(sum, ((PolygonGeography) feature).polygon.getCentroid());
+        }
+      }
+      return sum;
+    }
+    if (g instanceof GeographyCollection) {
+      S2Point sum = null;
+      for (Geography feature : ((GeographyCollection) g).getFeatures()) {
+        sum = addOrInit(sum, sphericalCentroid(feature));
+      }
+      return sum;
+    }
+    return null;
+  }
+
+  private static S2Point addOrInit(S2Point sum, S2Point next) {
+    if (next == null) return sum;
+    return (sum == null) ? next : S2Point.add(sum, next);
+  }
+
   /** Return the number of sub-geometries in a geography (1 for singles). */
   public static int numGeometries(Geography g) {
     if (g == null) return 0;
diff --git a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
index c722a1d586..88f41cef29 100644
--- a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
+++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
@@ -148,6 +148,99 @@ public class FunctionTest {
     assertEquals(5, Functions.nPoints(g));
   }
 
+  // S2 area-weighted centroids on small polygons differ from planar by an O(d^2/R^2)
+  // spherical correction. 5e-3 deg (~500 m) is wide enough to absorb that drift on
+  // 1°-scale shapes near origin, while still catching any real bug (a planar/JTS centroid
+  // of an antimeridian polygon would be off by ~180°, not by hundreds of metres).
+  private static final double CENTROID_TOL_DEG = 5e-3;
+
+  @Test
+  public void centroid_squarePolygon() throws ParseException {
+    Geography g = Constructors.geogFromWKT("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    assertEquals(4326, c.getSRID());
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    assertEquals(1.0, p.getX(), CENTROID_TOL_DEG);
+    assertEquals(1.0, p.getY(), CENTROID_TOL_DEG);
+  }
+
+  @Test
+  public void centroid_linestring() throws ParseException {
+    Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 2 0)", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    assertEquals(1.0, p.getX(), CENTROID_TOL_DEG);
+    assertEquals(0.0, p.getY(), CENTROID_TOL_DEG);
+  }
+
+  @Test
+  public void centroid_point() throws ParseException {
+    Geography g = Constructors.geogFromWKT("POINT (3 4)", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    // A single point's centroid is the point itself — exact.
+    assertEquals(3.0, p.getX(), 1e-9);
+    assertEquals(4.0, p.getY(), 1e-9);
+  }
+
+  @Test
+  public void centroid_multipoint_meanOfUnitVectors() throws ParseException {
+    Geography g = Constructors.geogFromWKT("MULTIPOINT ((-1 0), (1 0))", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    // Mean of unit vectors at (-1, 0) and (1, 0) lands on (0, 0).
+    assertEquals(0.0, p.getX(), CENTROID_TOL_DEG);
+    assertEquals(0.0, p.getY(), CENTROID_TOL_DEG);
+  }
+
+  @Test
+  public void centroid_multipolygon() throws ParseException {
+    // Two unit squares at (0..1, 0..1) and (10..11, 0..1). Equal area, so the area-weighted
+    // centroid should sit at the midpoint between the per-polygon centroids ≈ (5.5, 0.5).
+    Geography g =
+        Constructors.geogFromWKT(
+            "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((10 0, 11 0, 11 1, 10 1, 10 0)))", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    assertEquals(5.5, p.getX(), CENTROID_TOL_DEG);
+    assertEquals(0.5, p.getY(), CENTROID_TOL_DEG);
+  }
+
+  @Test
+  public void centroid_antimeridianPolygon_isOnTheAntimeridian() throws ParseException {
+    // Thin band straddling 180°E. A planar JTS centroid would average the lons and land
+    // at lon ≈ 0 (the wrong side of the planet). The spherical centroid stays near ±180.
+    Geography g =
+        Constructors.geogFromWKT("POLYGON ((170 -1, -170 -1, -170 1, 170 1, 170 -1))", 4326);
+    Geography c = Functions.centroid(g);
+    assertNotNull(c);
+    org.locationtech.jts.geom.Point p =
+        (org.locationtech.jts.geom.Point) Constructors.geogToGeometry(c);
+    double lon = p.getX();
+    double lat = p.getY();
+    // |lon| close to 180 (either side of the wrap), lat close to 0.
+    double lonDistFromAntimeridian = Math.min(Math.abs(lon - 180.0), Math.abs(lon + 180.0));
+    assertTrue(
+        "expected centroid near the antimeridian; got (" + lon + ", " + lat + ")",
+        lonDistFromAntimeridian < 0.5);
+    assertEquals(0.0, lat, 0.5);
+  }
+
+  @Test
+  public void centroid_nullHandling() {
+    assertNull(Functions.centroid(null));
+  }
+
   @Test
   public void numGeometries_point() throws ParseException {
     Geography g = Constructors.geogFromWKT("POINT (1 2)", 4326);
diff --git a/docs/api/sql/geography/Geography-Functions.md b/docs/api/sql/geography/Geography-Functions.md
index 3837e213dc..5daaf5aaf1 100644
--- a/docs/api/sql/geography/Geography-Functions.md
+++ b/docs/api/sql/geography/Geography-Functions.md
@@ -44,6 +44,7 @@ These functions operate on geography type objects.
 | [ST_Area](Geography-Functions/ST_Area.md) | Double | Return the geodesic area of a geography in square meters (WGS84 spheroid). | v1.9.1 |
 | [ST_AsEWKT](Geography-Functions/ST_AsEWKT.md) | String | Return the Extended Well-Known Text representation of a geography. | v1.8.0 |
 | [ST_AsText](Geography-Functions/ST_AsText.md) | String | Return the Well-Known Text (WKT) representation of a geography. | v1.9.1 |
+| [ST_Centroid](Geography-Functions/ST_Centroid.md) | Geography | Return the planar centroid of a geography as a Geography point (computed in projected lon/lat space). | v1.9.1 |
 | [ST_Buffer](Geography-Functions/ST_Buffer.md) | Geography | Return the metric ε-buffer of a geography. Distance is always interpreted as meters along the spheroid. | v1.9.1 |
 | [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 |
 | [ST_GeometryType](Geography-Functions/ST_GeometryType.md) | String | Return the type of a geography as a string (e.g., "ST_Point", "ST_Polygon"). | v1.9.1 |
diff --git a/docs/api/sql/geography/Geography-Functions/ST_Centroid.md b/docs/api/sql/geography/Geography-Functions/ST_Centroid.md
new file mode 100644
index 0000000000..ca24fe6129
--- /dev/null
+++ b/docs/api/sql/geography/Geography-Functions/ST_Centroid.md
@@ -0,0 +1,56 @@
+<!--
+ 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.
+ -->
+
+# ST_Centroid
+
+Introduction: Returns the spherical centroid of a geography as a Geography point, computed on the sphere using S2:
+
+- **Polygon / MultiPolygon** — area-weighted centroid via `S2Polygon.getCentroid()`.
+- **LineString / MultiLineString** — length-weighted centroid via `S2Polyline.getCentroid()`.
+- **Point / MultiPoint** — mean of the unit vectors.
+- **GeographyCollection** — recursive weighted sum across the children.
+
+The result is the unit-length centroid on the sphere. Unlike a planar (lon/lat) centroid, it is correct for antimeridian-crossing and high-latitude geographies. As with JTS for non-convex shapes, the centroid may lie outside the input geometry. Returns `NULL` when the centroid is undefined (empty geometry, or antipodal points whose unit vectors cancel).
+
+Format:
+
+`ST_Centroid (A: Geography)`
+
+Return type: `Geography`
+
+Since: `v1.9.1`
+
+SQL Example
+
+```sql
+SELECT ST_AsEWKT(ST_Centroid(ST_GeogFromWKT('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))')));
+```
+
+Output (small `O(d²/R²)` spherical correction vs the planar `(1 1)`):
+
+```
+POINT (1 1)
+```
+
+For an antimeridian-crossing polygon, the spherical centroid stays on the antimeridian instead of jumping to the opposite side of the planet, which a planar centroid would do:
+
+```sql
+SELECT ST_AsEWKT(ST_Centroid(ST_GeogFromWKT('POLYGON ((170 -1, -170 -1, -170 1, 170 1, 170 -1))')));
+-- result: POINT near (180, 0) (or (-180, 0)), NOT (0, 0)
+```
diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
index cb6b75cb81..da4cf3b653 100644
--- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
+++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
@@ -301,7 +301,9 @@ private[apache] case class ST_Area(inputExpressions: Seq[Expression])
  * @param inputExpressions
  */
 private[apache] case class ST_Centroid(inputExpressions: Seq[Expression])
-    extends InferredExpression(Functions.getCentroid _) {
+    extends InferredExpression(
+      inferrableFunction1(Functions.getCentroid),
+      inferrableFunction1(org.apache.sedona.common.geography.Functions.centroid)) {
 
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
     copy(inputExpressions = newChildren)
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
index 1ac8ce62fd..b1cae8acb5 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
@@ -23,7 +23,7 @@ import org.apache.sedona.sql.TestBaseScala
 import org.apache.spark.sql.functions.{col, lit}
 import org.apache.spark.sql.sedona_sql.expressions.{st_constructors, st_functions, st_predicates}
 import org.junit.Assert.{assertEquals, assertNotNull, assertTrue}
-import org.locationtech.jts.geom.{Geometry, Point}
+import org.locationtech.jts.geom.Point
 import org.locationtech.jts.io.WKTReader
 
 /**
@@ -78,7 +78,7 @@ class GeographyFunctionTest extends TestBaseScala {
     }
   }
 
-  // ─── Level 1: ST_NPoints, ST_NumGeometries, ST_GeometryType, ST_AsText ──
+  // ─── Level 1: ST_NPoints, ST_Centroid, ST_NumGeometries, ST_GeometryType, ST_AsText ─
 
   describe("Level 1: Structural") {
 
@@ -89,6 +89,19 @@ class GeographyFunctionTest extends TestBaseScala {
       assertEquals(3, row.getInt(0))
     }
 
+    it("ST_Centroid square polygon") {
+      val row = sparkSession
+        .sql(
+          "SELECT ST_Centroid(ST_GeogFromWKT('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 4326)) AS c")
+        .first()
+      val centroid = row.get(0).asInstanceOf[Geography]
+      val point = new WKTReader().read(centroid.toString).asInstanceOf[Point]
+      // Spherical area-weighted centroid of a 2x2° square at origin is ~(1, 1) with an
+      // O(d^2/R^2) spherical correction; 5e-3° is well within that band.
+      assertEquals(1.0, point.getX, 5e-3)
+      assertEquals(1.0, point.getY, 5e-3)
+    }
+
     it("ST_NumGeometries single") {
       val row = sparkSession
         .sql("SELECT ST_NumGeometries(ST_GeogFromWKT('POINT (1 2)', 4326)) AS n")