This is an automated email from the ASF dual-hosted git repository.

jiayu 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 d13b692ccf [GH-2674] Add RS_SetCRS and RS_CRS for custom CRS string 
support (#2677)
d13b692ccf is described below

commit d13b692ccfd08f7804bdad30c160697e3ab24abf
Author: Jia Yu <[email protected]>
AuthorDate: Mon Mar 2 12:23:47 2026 -0700

    [GH-2674] Add RS_SetCRS and RS_CRS for custom CRS string support (#2677)
---
 .../sedona/common/raster/CrsNormalization.java     | 243 +++++++
 .../sedona/common/raster/RasterAccessors.java      | 101 +++
 .../apache/sedona/common/raster/RasterEditors.java | 159 +++++
 .../common/raster/CrsRoundTripComplianceTest.java  | 697 +++++++++++++++++++++
 .../sedona/common/raster/RasterAccessorsTest.java  |  68 ++
 .../sedona/common/raster/RasterEditorsTest.java    | 146 +++++
 docs/api/sql/Raster-Functions.md                   |   2 +
 docs/api/sql/Raster-Operators/RS_CRS.md            | 101 +++
 docs/api/sql/Raster-Operators/RS_SRID.md           |   2 +-
 docs/api/sql/Raster-Operators/RS_SetCRS.md         |  68 ++
 pom.xml                                            |   2 +-
 .../scala/org/apache/sedona/sql/UDF/Catalog.scala  |   2 +
 .../expressions/raster/RasterAccessors.scala       |   9 +
 .../expressions/raster/RasterEditors.scala         |   7 +
 .../org/apache/sedona/sql/rasteralgebraTest.scala  |  71 +++
 15 files changed, 1676 insertions(+), 2 deletions(-)

diff --git 
a/common/src/main/java/org/apache/sedona/common/raster/CrsNormalization.java 
b/common/src/main/java/org/apache/sedona/common/raster/CrsNormalization.java
new file mode 100644
index 0000000000..c342360339
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/raster/CrsNormalization.java
@@ -0,0 +1,243 @@
+/*
+ * 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.
+ */
+package org.apache.sedona.common.raster;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.geotools.api.referencing.operation.MathTransformFactory;
+import org.geotools.api.referencing.operation.OperationMethod;
+import org.geotools.api.referencing.operation.Projection;
+import org.geotools.referencing.ReferencingFactoryFinder;
+import org.geotools.referencing.operation.DefaultMathTransformFactory;
+
+/**
+ * Centralized CRS name normalization for bridging GeoTools ↔ proj4sedona 
naming differences.
+ *
+ * <p>GeoTools and proj4sedona use different canonical names for some map 
projections (e.g. GeoTools
+ * uses "Mercator_2SP" while proj4sedona uses "Mercator"). This class 
normalizes names once at the
+ * import/export boundaries so downstream code operates on compatible names.
+ *
+ * <p>All lookups use pre-computed maps; normalization is O(1) after class 
initialization.
+ */
+final class CrsNormalization {
+
+  private CrsNormalization() {}
+
+  // Shared regex for extracting projection names from WKT1 strings
+  static final Pattern PROJECTION_PATTERN = 
Pattern.compile("PROJECTION\\[\"([^\"]+)\"\\]");
+
+  // =====================================================================
+  // Import direction: proj4sedona → GeoTools
+  // =====================================================================
+
+  // Hardcoded fallback for proj4sedona names with no GeoTools alias.
+  // Keys are pre-normalized (lowercase, no spaces/underscores) for O(1) 
lookup.
+  // Verified via exhaustive testing of all 58 proj4sedona registered 
projection names.
+  private static final Map<String, String> PROJ4SEDONA_TO_GEOTOOLS;
+
+  static {
+    Map<String, String> m = new HashMap<>();
+    m.put("lambertcylindricalequalarea", "Cylindrical_Equal_Area");
+    m.put("extendedtransversemercator", "Transverse_Mercator");
+    m.put("lamberttangentialconformalconicprojection", 
"Lambert_Conformal_Conic");
+    m.put("mercatorvarianta", "Mercator_1SP");
+    m.put("polarstereographicvarianta", "Polar_Stereographic");
+    m.put("polarstereographicvariantb", "Polar_Stereographic");
+    m.put("universaltransversemercatorsystem", "Transverse_Mercator");
+    m.put("universaltransversemercator", "Transverse_Mercator");
+    PROJ4SEDONA_TO_GEOTOOLS = m;
+  }
+
+  // =====================================================================
+  // Export direction: GeoTools → proj4sedona
+  // =====================================================================
+
+  // GeoTools canonical names that proj4sedona does not recognize.
+  private static final Map<String, String> GEOTOOLS_TO_PROJ4SEDONA;
+
+  static {
+    Map<String, String> m = new HashMap<>();
+    m.put("Mercator_2SP", "Mercator");
+    GEOTOOLS_TO_PROJ4SEDONA = m;
+  }
+
+  // =====================================================================
+  // GeoTools alias caches (lazy-initialized, thread-safe)
+  // =====================================================================
+
+  // aliasCache: exact alias string → canonical OGC name
+  // normalizedCache: normalized form → set of canonical names (for 
disambiguation)
+  private static volatile Map<String, String> aliasCache;
+  private static volatile Map<String, Set<String>> normalizedCache;
+
+  // =====================================================================
+  // Public API
+  // =====================================================================
+
+  /**
+   * Normalize WKT1 projection names from proj4sedona output for GeoTools 
consumption. Uses a
+   * three-tier resolution strategy:
+   *
+   * <ol>
+   *   <li><b>Exact alias matching</b> — direct lookup against all GeoTools 
registered aliases (OGC,
+   *       EPSG, GeoTIFF, ESRI, PROJ authorities).
+   *   <li><b>Normalized matching</b> — case-insensitive, ignoring 
spaces/underscores. Only used
+   *       when the normalized form maps unambiguously to a single canonical 
name.
+   *   <li><b>Hardcoded fallback</b> — pre-normalized lookup for 
proj4sedona-specific names that
+   *       have no equivalent in GeoTools' alias database.
+   * </ol>
+   *
+   * @param wkt1 The WKT1 string from proj4sedona.
+   * @return The WKT1 string with the projection name normalized for GeoTools.
+   */
+  static String normalizeWkt1ForGeoTools(String wkt1) {
+    return replaceProjectionName(wkt1, CrsNormalization::resolveForGeoTools);
+  }
+
+  /**
+   * Normalize WKT1 projection names from GeoTools output for proj4sedona 
consumption.
+   *
+   * @param wkt1 The WKT1 string from GeoTools.
+   * @return The WKT1 string with the projection name normalized for 
proj4sedona.
+   */
+  static String normalizeWkt1ForProj4sedona(String wkt1) {
+    return replaceProjectionName(wkt1, GEOTOOLS_TO_PROJ4SEDONA::get);
+  }
+
+  // =====================================================================
+  // Internal implementation
+  // =====================================================================
+
+  /** Functional interface for projection name lookup. */
+  @FunctionalInterface
+  private interface NameResolver {
+    /** Return the replacement name, or null if no mapping exists. */
+    String resolve(String projName);
+  }
+
+  /**
+   * Replace the projection name in a WKT1 string using the given resolver. 
Shared logic for both
+   * import and export directions.
+   */
+  private static String replaceProjectionName(String wkt1, NameResolver 
resolver) {
+    Matcher m = PROJECTION_PATTERN.matcher(wkt1);
+    if (m.find()) {
+      String projName = m.group(1);
+      String resolved = resolver.resolve(projName);
+      if (resolved != null && !resolved.equals(projName)) {
+        return wkt1.substring(0, m.start(1)) + resolved + 
wkt1.substring(m.end(1));
+      }
+    }
+    return wkt1;
+  }
+
+  /**
+   * Three-tier resolution: proj4sedona projection name → canonical GeoTools 
name.
+   *
+   * @return The resolved name, or null if the name is already compatible.
+   */
+  private static String resolveForGeoTools(String projName) {
+    ensureCachesBuilt();
+
+    // Tier 1: Exact alias match from GeoTools
+    String resolved = aliasCache.get(projName);
+    if (resolved != null) {
+      return resolved;
+    }
+
+    // Tier 2: Normalized match (handles space/underscore/case differences)
+    String normalized = normalizeForMatch(projName);
+    Set<String> candidates = normalizedCache.get(normalized);
+    if (candidates != null && candidates.size() == 1) {
+      String canonical = candidates.iterator().next();
+      aliasCache.put(projName, canonical); // cache for next time
+      return canonical;
+    }
+
+    // Tier 3: Pre-normalized hardcoded fallback (O(1) lookup)
+    String fallback = PROJ4SEDONA_TO_GEOTOOLS.get(normalized);
+    if (fallback != null) {
+      aliasCache.put(projName, fallback); // cache for next time
+      return fallback;
+    }
+
+    return null; // name is already compatible or unknown
+  }
+
+  /**
+   * Normalize a string for loose matching: lowercase, remove spaces and 
underscores.
+   *
+   * @param name The name to normalize.
+   * @return Normalized form (e.g. "Lambert_Conformal_Conic_2SP" → 
"lambertconformalconic2sp").
+   */
+  static String normalizeForMatch(String name) {
+    return name.toLowerCase(Locale.ROOT).replaceAll("[_ ]", "");
+  }
+
+  /**
+   * Build GeoTools alias caches from all registered {@link OperationMethod} 
objects. Thread-safe
+   * via double-checked locking. Called at most once.
+   */
+  private static void ensureCachesBuilt() {
+    if (aliasCache != null) {
+      return;
+    }
+    synchronized (CrsNormalization.class) {
+      if (aliasCache != null) {
+        return;
+      }
+      MathTransformFactory mtf = 
ReferencingFactoryFinder.getMathTransformFactory(null);
+      DefaultMathTransformFactory factory;
+      if (mtf instanceof DefaultMathTransformFactory) {
+        factory = (DefaultMathTransformFactory) mtf;
+      } else {
+        factory = new DefaultMathTransformFactory();
+      }
+      Set<OperationMethod> methods = 
factory.getAvailableMethods(Projection.class);
+
+      Map<String, String> aliases = new ConcurrentHashMap<>();
+      Map<String, Set<String>> normalized = new HashMap<>();
+
+      for (OperationMethod method : methods) {
+        String canonical = method.getName().getCode();
+        aliases.put(canonical, canonical);
+        normalized
+            .computeIfAbsent(normalizeForMatch(canonical), k -> new 
HashSet<>())
+            .add(canonical);
+        if (method.getAlias() != null) {
+          for (Object alias : method.getAlias()) {
+            String aliasName = alias.toString().replaceAll("^[^:]+:", "");
+            aliases.put(aliasName, canonical);
+            normalized
+                .computeIfAbsent(normalizeForMatch(aliasName), k -> new 
HashSet<>())
+                .add(canonical);
+          }
+        }
+      }
+      aliasCache = aliases;
+      normalizedCache = normalized;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/sedona/common/raster/RasterAccessors.java 
b/common/src/main/java/org/apache/sedona/common/raster/RasterAccessors.java
index 49a9223908..4f4a9f5df2 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/RasterAccessors.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/RasterAccessors.java
@@ -21,8 +21,10 @@ package org.apache.sedona.common.raster;
 import java.awt.geom.Point2D;
 import java.awt.image.RenderedImage;
 import java.util.Arrays;
+import java.util.Locale;
 import java.util.Set;
 import org.apache.sedona.common.utils.RasterUtils;
+import org.datasyslab.proj4sedona.core.Proj;
 import org.geotools.api.referencing.FactoryException;
 import org.geotools.api.referencing.ReferenceIdentifier;
 import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
@@ -31,12 +33,14 @@ import org.geotools.coverage.grid.GridCoverage2D;
 import org.geotools.coverage.grid.GridEnvelope2D;
 import org.geotools.referencing.crs.DefaultEngineeringCRS;
 import org.geotools.referencing.operation.transform.AffineTransform2D;
+import org.geotools.referencing.wkt.Formattable;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.Geometry;
 import org.locationtech.jts.geom.GeometryFactory;
 import org.locationtech.jts.geom.Point;
 
 public class RasterAccessors {
+
   public static int srid(GridCoverage2D raster) throws FactoryException {
     CoordinateReferenceSystem crs = raster.getCoordinateReferenceSystem();
     if (crs instanceof DefaultEngineeringCRS) {
@@ -359,4 +363,101 @@ public class RasterAccessors {
         (int) meta[10],
         (int) meta[11]);
   }
+
+  /**
+   * Returns the CRS of a raster as PROJJSON string.
+   *
+   * @param raster The input raster.
+   * @return The CRS definition as PROJJSON string, or null if no CRS is set.
+   */
+  public static String crs(GridCoverage2D raster) {
+    return crs(raster, "projjson");
+  }
+
+  /**
+   * Returns the CRS of a raster in the specified format.
+   *
+   * @param raster The input raster.
+   * @param format The desired output format: "projjson", "wkt2", "wkt1", or 
"proj".
+   * @return The CRS definition string in the requested format, or null if no 
CRS is set.
+   */
+  public static String crs(GridCoverage2D raster, String format) {
+    String fmt;
+    if (format == null || format.trim().isEmpty()) {
+      fmt = "projjson";
+    } else {
+      fmt = format.toLowerCase(Locale.ROOT).trim();
+    }
+    CoordinateReferenceSystem crsDef = raster.getCoordinateReferenceSystem();
+    if (crsDef instanceof DefaultEngineeringCRS) {
+      if (((DefaultEngineeringCRS) crsDef).isWildcard()) {
+        return null;
+      }
+    }
+
+    // Get WKT1 representation from GeoTools (native, no conversion needed)
+    String wkt1;
+    if (crsDef instanceof Formattable) {
+      wkt1 = ((Formattable) crsDef).toWKT(2, false);
+    } else {
+      wkt1 = crsDef.toWKT();
+    }
+
+    if ("wkt1".equals(fmt) || "wkt".equals(fmt)) {
+      return wkt1;
+    }
+
+    // For all other formats, convert through proj4sedona.
+    // Prefer EPSG SRID when available: GeoTools WKT1 projection names (e.g. 
Mercator_2SP)
+    // may not be recognized by proj4sedona, but EPSG codes always work.
+    try {
+      Proj proj;
+      int srid = srid(raster);
+      if (srid > 0) {
+        try {
+          proj = new Proj("EPSG:" + srid);
+        } catch (Exception e) {
+          // EPSG code not recognized by proj4sedona, fall back to WKT1
+          proj = createProjFromWkt1(wkt1);
+        }
+      } else {
+        proj = createProjFromWkt1(wkt1);
+      }
+      switch (fmt) {
+        case "projjson":
+          return proj.toProjJson();
+        case "wkt2":
+          return proj.toWkt2();
+        case "proj":
+        case "proj4":
+          return proj.toProjString();
+        default:
+          throw new IllegalArgumentException(
+              "Unsupported CRS format: '"
+                  + format
+                  + "'. Supported formats: projjson, wkt2, wkt1, proj");
+      }
+    } catch (IllegalArgumentException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new RuntimeException(
+          "Failed to convert CRS to format '" + format + "': " + 
e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Create a Proj object from GeoTools WKT1, with normalization fallback for 
projection names that
+   * proj4sedona does not recognize (e.g. Mercator_2SP).
+   */
+  private static Proj createProjFromWkt1(String wkt1) {
+    try {
+      return new Proj(wkt1);
+    } catch (Exception wktError) {
+      String normalized = CrsNormalization.normalizeWkt1ForProj4sedona(wkt1);
+      if (!normalized.equals(wkt1)) {
+        return new Proj(normalized);
+      }
+      throw wktError;
+    }
+  }
 }
diff --git 
a/common/src/main/java/org/apache/sedona/common/raster/RasterEditors.java 
b/common/src/main/java/org/apache/sedona/common/raster/RasterEditors.java
index f28fd77c6a..818ba65e83 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/RasterEditors.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/RasterEditors.java
@@ -26,15 +26,19 @@ import java.awt.image.*;
 import java.util.Arrays;
 import java.util.Map;
 import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import javax.media.jai.Interpolation;
 import javax.media.jai.RasterFactory;
 import org.apache.sedona.common.FunctionsGeoTools;
 import org.apache.sedona.common.utils.RasterInterpolate;
 import org.apache.sedona.common.utils.RasterUtils;
+import org.datasyslab.proj4sedona.core.Proj;
 import org.geotools.api.coverage.grid.GridCoverage;
 import org.geotools.api.coverage.grid.GridGeometry;
 import org.geotools.api.metadata.spatial.PixelOrientation;
 import org.geotools.api.referencing.FactoryException;
+import org.geotools.api.referencing.crs.CRSFactory;
 import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
 import org.geotools.api.referencing.datum.PixelInCell;
 import org.geotools.api.referencing.operation.MathTransform;
@@ -48,8 +52,11 @@ import org.geotools.coverage.grid.GridEnvelope2D;
 import org.geotools.coverage.grid.GridGeometry2D;
 import org.geotools.coverage.processing.Operations;
 import org.geotools.geometry.jts.ReferencedEnvelope;
+import org.geotools.referencing.CRS;
+import org.geotools.referencing.ReferencingFactoryFinder;
 import org.geotools.referencing.crs.DefaultEngineeringCRS;
 import org.geotools.referencing.operation.transform.AffineTransform2D;
+import org.geotools.util.factory.Hints;
 import org.locationtech.jts.index.strtree.STRtree;
 
 public class RasterEditors {
@@ -102,7 +109,159 @@ public class RasterEditors {
     } else {
       crs = FunctionsGeoTools.sridToCRS(srid);
     }
+    return replaceCrs(raster, crs);
+  }
+
+  /**
+   * Sets the CRS of a raster using a CRS string. Accepts EPSG codes (e.g. 
"EPSG:4326"), WKT1, WKT2,
+   * PROJ strings, and PROJJSON.
+   *
+   * @param raster The input raster.
+   * @param crsString The CRS definition string.
+   * @return The raster with the new CRS.
+   */
+  public static GridCoverage2D setCrs(GridCoverage2D raster, String crsString) 
{
+    CoordinateReferenceSystem crs = parseCrsString(crsString);
+    return replaceCrs(raster, crs);
+  }
+
+  /**
+   * Parse a CRS string in any supported format into a GeoTools 
CoordinateReferenceSystem.
+   *
+   * <p>Parsing priority:
+   *
+   * <ol>
+   *   <li>GeoTools CRS.decode — handles authority codes like EPSG:4326
+   *   <li>GeoTools CRS.parseWKT — handles WKT1 strings
+   *   <li>proj4sedona — handles WKT2, PROJ strings, PROJJSON. If an EPSG 
authority can be resolved,
+   *       uses CRS.decode for a lossless result. Otherwise falls back to WKT1 
conversion.
+   * </ol>
+   *
+   * @param crsString The CRS definition string.
+   * @return The parsed CoordinateReferenceSystem.
+   * @throws IllegalArgumentException if the CRS string cannot be parsed.
+   */
+  static CoordinateReferenceSystem parseCrsString(String crsString) {
+    if (crsString == null || crsString.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "CRS string must not be null or empty. "
+              + "Supported formats: EPSG code (e.g. 'EPSG:4326'), WKT1, WKT2, 
PROJ string, PROJJSON.");
+    }
+
+    // Step 1: Try GeoTools CRS.decode (handles EPSG:xxxx, AUTO:xxxx, etc.)
+    try {
+      return CRS.decode(crsString, true);
+    } catch (FactoryException e) {
+      // Not an authority code, continue
+    }
+
+    // Step 2: Try GeoTools WKT parsing with longitude-first axis order 
(handles WKT1)
+    Hints hints = new Hints(Hints.FORCE_LONGITUDE_FIRST_AXIS_ORDER, 
Boolean.TRUE);
+    CRSFactory crsFactory = ReferencingFactoryFinder.getCRSFactory(hints);
+    try {
+      return crsFactory.createFromWKT(crsString);
+    } catch (FactoryException e) {
+      // Not WKT1, continue
+    }
+
+    // Step 3: Use proj4sedona (handles WKT2, PROJ, PROJJSON)
+    Exception lastError = null;
+    try {
+      Proj proj = new Proj(crsString);
+
+      // Try to resolve to an EPSG authority code for a lossless result
+      String authority = proj.toEpsgCode();
+      if (authority != null && !authority.isEmpty()) {
+        try {
+          return CRS.decode(authority, true);
+        } catch (FactoryException ex) {
+          // Authority code not recognized by GeoTools, fall through to WKT1
+        }
+      }
+
+      // Fallback: convert to WKT1 via proj4sedona and parse with GeoTools.
+      // proj4sedona may include parameters GeoTools doesn't expect (e.g. 
standard_parallel_1
+      // for projections that don't use it). We handle this by trying several 
parse strategies:
+      // 1. Raw WKT1 (proj4sedona's projection names may already be recognized 
by GeoTools)
+      // 2. Normalized WKT1 (resolve projection names to canonical OGC names)
+      // 3. Strip unexpected parameters iteratively
+      String wkt1 = proj.toWkt1();
+      if (wkt1 != null && !wkt1.isEmpty()) {
+
+        // Strategy 1: Try raw WKT1 directly
+        try {
+          return crsFactory.createFromWKT(wkt1);
+        } catch (FactoryException ex) {
+          // Raw WKT1 failed, continue with normalization
+        }
+
+        // Strategy 2: Try with normalized projection name
+        String normalizedWkt = CrsNormalization.normalizeWkt1ForGeoTools(wkt1);
+        // Strategy 3: If parsing fails due to unexpected parameters, strip 
them iteratively.
+        // proj4sedona sometimes includes parameters like standard_parallel_1 
for projections
+        // that don't use it. We parse the error message to identify and 
remove the offending
+        // parameter, then retry.
+        String currentWkt = normalizedWkt;
+        for (int attempt = 0; attempt < 5; attempt++) {
+          try {
+            return crsFactory.createFromWKT(currentWkt);
+          } catch (FactoryException ex) {
+            lastError = ex;
+            String msg = ex.getMessage();
+            if (msg != null) {
+              Matcher paramMatcher = UNEXPECTED_PARAM_PATTERN.matcher(msg);
+              if (paramMatcher.find()) {
+                String stripped = stripWktParameter(currentWkt, 
paramMatcher.group(1));
+                if (stripped.equals(currentWkt)) {
+                  break; // Strip was a no-op, give up
+                }
+                currentWkt = stripped;
+                continue;
+              }
+            }
+            break; // Different kind of error, give up
+          }
+        }
+      }
+    } catch (RuntimeException e) {
+      lastError = e;
+    }
+
+    IllegalArgumentException error =
+        new IllegalArgumentException(
+            "Cannot parse CRS string. Supported formats: EPSG code (e.g. 
'EPSG:4326'), "
+                + "WKT1, WKT2, PROJ string, PROJJSON. Input: "
+                + crsString);
+    if (lastError != null) {
+      error.addSuppressed(lastError);
+    }
+    throw error;
+  }
+
+  private static final Pattern UNEXPECTED_PARAM_PATTERN =
+      Pattern.compile("Parameter \"([^\"]+)\" was not expected");
+
+  /**
+   * Strip a named PARAMETER from a WKT1 string. Used to remove parameters 
that proj4sedona includes
+   * but GeoTools does not expect (e.g. standard_parallel_1 for Transverse 
Mercator).
+   *
+   * @param wkt The WKT1 string.
+   * @param paramName The parameter name to strip (e.g. "standard_parallel_1").
+   * @return The WKT1 string with the parameter removed.
+   */
+  private static String stripWktParameter(String wkt, String paramName) {
+    // Remove ,PARAMETER["paramName",value] or PARAMETER["paramName",value],
+    String escaped = Pattern.quote(paramName);
+    Pattern pattern = Pattern.compile(",\\s*PARAMETER\\[\"" + escaped + 
"\",[^\\]]*\\]");
+    String result = pattern.matcher(wkt).replaceAll("");
+    if (result.equals(wkt)) {
+      Pattern pattern2 = Pattern.compile("PARAMETER\\[\"" + escaped + 
"\",[^\\]]*\\]\\s*,?");
+      result = pattern2.matcher(wkt).replaceAll("");
+    }
+    return result;
+  }
 
+  private static GridCoverage2D replaceCrs(GridCoverage2D raster, 
CoordinateReferenceSystem crs) {
     GridCoverageFactory gridCoverageFactory = 
CoverageFactoryFinder.getGridCoverageFactory(null);
     MathTransform2D transform = raster.getGridGeometry().getGridToCRS2D();
     Map<?, ?> properties = raster.getProperties();
diff --git 
a/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java
 
b/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java
new file mode 100644
index 0000000000..19cc815491
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/raster/CrsRoundTripComplianceTest.java
@@ -0,0 +1,697 @@
+/*
+ * 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.
+ */
+package org.apache.sedona.common.raster;
+
+import static org.junit.Assert.*;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.geotools.api.referencing.FactoryException;
+import org.geotools.coverage.grid.GridCoverage2D;
+import org.junit.Test;
+
+/**
+ * Round-trip compliance tests for RS_SetCRS and RS_CRS across representative 
EPSG codes.
+ *
+ * <p>For each EPSG code and each format (PROJ, PROJJSON, WKT1, WKT2), this 
test:
+ *
+ * <ol>
+ *   <li>Creates a raster with that CRS via RS_SetCRS("EPSG:xxxx")
+ *   <li>Exports the CRS via RS_CRS(raster, format)
+ *   <li>Re-imports the exported string via RS_SetCRS(exportedString)
+ *   <li>Re-exports via RS_CRS(raster2, format) and verifies the exported 
string is identical
+ * </ol>
+ */
+public class CrsRoundTripComplianceTest extends RasterTestBase {
+
+  private static final Pattern WKT1_PROJECTION_PATTERN =
+      Pattern.compile("PROJECTION\\[\"([^\"]+)\"");
+  private static final Pattern WKT1_AUTHORITY_PATTERN =
+      Pattern.compile("AUTHORITY\\[\"EPSG\",\\s*\"(\\d+)\"\\]");
+
+  // 
---------------------------------------------------------------------------
+  // PROJ format round-trip tests
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void testProjRoundTrip_Geographic_4326() throws FactoryException {
+    assertProjRoundTrip(4326);
+  }
+
+  @Test
+  public void testProjRoundTrip_Geographic_NAD83_4269() throws 
FactoryException {
+    assertProjRoundTrip(4269);
+  }
+
+  @Test
+  public void testProjRoundTrip_TransverseMercator_32617() throws 
FactoryException {
+    assertProjRoundTrip(32617);
+  }
+
+  @Test
+  public void testProjRoundTrip_PseudoMercator_3857() throws FactoryException {
+    assertProjRoundTrip(3857);
+  }
+
+  @Test
+  public void testProjRoundTrip_Mercator1SP_3395() throws FactoryException {
+    assertProjRoundTrip(3395);
+  }
+
+  @Test
+  public void testProjRoundTrip_LambertConformalConic2SP_2154() throws 
FactoryException {
+    assertProjRoundTrip(2154);
+  }
+
+  @Test
+  public void testProjRoundTrip_LambertAzimuthalEqualArea_Spherical_2163() 
throws FactoryException {
+    assertProjRoundTrip(2163);
+  }
+
+  @Test
+  public void testProjRoundTrip_AlbersEqualArea_5070() throws FactoryException 
{
+    assertProjRoundTrip(5070);
+  }
+
+  @Test
+  public void testProjRoundTrip_ObliqueStereographic_28992() throws 
FactoryException {
+    assertProjRoundTrip(28992);
+  }
+
+  @Test
+  public void testProjRoundTrip_PolarStereographicB_3031() throws 
FactoryException {
+    assertProjRoundTrip(3031);
+  }
+
+  @Test
+  public void testProjRoundTrip_LambertAzimuthalEqualArea_3035() throws 
FactoryException {
+    assertProjRoundTrip(3035);
+  }
+
+  @Test
+  public void testProjRoundTrip_Mercator1SP_Spherical_3785() throws 
FactoryException {
+    assertProjRoundTrip(3785);
+  }
+
+  @Test
+  public void testProjRoundTrip_EquidistantCylindrical_4087() throws 
FactoryException {
+    assertProjRoundTrip(4087);
+  }
+
+  @Test
+  public void testProjRoundTrip_PolarStereographicA_32661() throws 
FactoryException {
+    assertProjRoundTrip(32661);
+  }
+
+  @Test
+  public void testProjRoundTrip_TransverseMercator_OSGB_27700() throws 
FactoryException {
+    assertProjRoundTrip(27700);
+  }
+
+  @Test
+  public void testProjRoundTrip_AlbersEqualArea_Australian_3577() throws 
FactoryException {
+    assertProjRoundTrip(3577);
+  }
+
+  @Test
+  public void testProjRoundTrip_LambertConformalConic2SP_Vicgrid_3111() throws 
FactoryException {
+    assertProjRoundTrip(3111);
+  }
+
+  @Test
+  public void testProjRoundTrip_PolarStereographicB_NSIDC_3413() throws 
FactoryException {
+    assertProjRoundTrip(3413);
+  }
+
+  @Test
+  public void testProjRoundTrip_LambertAzimuthalEqualArea_EASE_6931() throws 
FactoryException {
+    assertProjRoundTrip(6931);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // PROJJSON format round-trip tests
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void testProjJsonRoundTrip_Geographic_4326() throws FactoryException {
+    assertProjJsonRoundTrip(4326);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_Geographic_NAD83_4269() throws 
FactoryException {
+    assertProjJsonRoundTrip(4269);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_TransverseMercator_32617() throws 
FactoryException {
+    assertProjJsonRoundTrip(32617);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_PseudoMercator_3857() throws 
FactoryException {
+    assertProjJsonRoundTrip(3857);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_Mercator1SP_3395() throws FactoryException 
{
+    assertProjJsonRoundTrip(3395);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_LambertConformalConic2SP_2154() throws 
FactoryException {
+    assertProjJsonRoundTrip(2154);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_AlbersEqualArea_5070() throws 
FactoryException {
+    assertProjJsonRoundTrip(5070);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_ObliqueStereographic_28992() throws 
FactoryException {
+    assertProjJsonRoundTrip(28992);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_PolarStereographicB_3031() throws 
FactoryException {
+    assertProjJsonRoundTrip(3031);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_LambertAzimuthalEqualArea_3035() throws 
FactoryException {
+    assertProjJsonRoundTrip(3035);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_EquidistantCylindrical_4087() throws 
FactoryException {
+    assertProjJsonRoundTrip(4087);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_PolarStereographicA_32661() throws 
FactoryException {
+    assertProjJsonRoundTrip(32661);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_TransverseMercator_OSGB_27700() throws 
FactoryException {
+    assertProjJsonRoundTrip(27700);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_AlbersEqualArea_Australian_3577() throws 
FactoryException {
+    assertProjJsonRoundTrip(3577);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_LambertConformalConic2SP_Vicgrid_3111()
+      throws FactoryException {
+    assertProjJsonRoundTrip(3111);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_PolarStereographicB_NSIDC_3413() throws 
FactoryException {
+    assertProjJsonRoundTrip(3413);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_LambertAzimuthalEqualArea_EASE_6931() 
throws FactoryException {
+    assertProjJsonRoundTrip(6931);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // WKT1 format round-trip tests
+  // WKT1 includes AUTHORITY["EPSG","xxxx"] so SRID is always preserved.
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void testWkt1RoundTrip_Geographic_4326() throws FactoryException {
+    assertWkt1RoundTrip(4326);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_Geographic_NAD83_4269() throws 
FactoryException {
+    assertWkt1RoundTrip(4269);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_TransverseMercator_32617() throws 
FactoryException {
+    assertWkt1RoundTrip(32617);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_PseudoMercator_3857() throws FactoryException {
+    assertWkt1RoundTrip(3857);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_Mercator1SP_3395() throws FactoryException {
+    assertWkt1RoundTrip(3395);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_LambertConformalConic2SP_2154() throws 
FactoryException {
+    assertWkt1RoundTrip(2154);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_LambertAzimuthalEqualArea_Spherical_2163() 
throws FactoryException {
+    assertWkt1RoundTrip(2163);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_AlbersEqualArea_5070() throws FactoryException 
{
+    assertWkt1RoundTrip(5070);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_ObliqueStereographic_28992() throws 
FactoryException {
+    assertWkt1RoundTrip(28992);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_PolarStereographicB_3031() throws 
FactoryException {
+    assertWkt1RoundTrip(3031);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_LambertAzimuthalEqualArea_3035() throws 
FactoryException {
+    assertWkt1RoundTrip(3035);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_Mercator1SP_Spherical_3785() throws 
FactoryException {
+    assertWkt1RoundTrip(3785);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_EquidistantCylindrical_4087() throws 
FactoryException {
+    assertWkt1RoundTrip(4087);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_PolarStereographicA_32661() throws 
FactoryException {
+    assertWkt1RoundTrip(32661);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_TransverseMercator_OSGB_27700() throws 
FactoryException {
+    assertWkt1RoundTrip(27700);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_AlbersEqualArea_Australian_3577() throws 
FactoryException {
+    assertWkt1RoundTrip(3577);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_LambertConformalConic2SP_Vicgrid_3111() throws 
FactoryException {
+    assertWkt1RoundTrip(3111);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_PolarStereographicB_NSIDC_3413() throws 
FactoryException {
+    assertWkt1RoundTrip(3413);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_LambertAzimuthalEqualArea_EASE_6931() throws 
FactoryException {
+    assertWkt1RoundTrip(6931);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_Krovak_2065() throws FactoryException {
+    // Krovak fails for PROJ/PROJJSON export but WKT1 is GeoTools-native, so 
it works
+    assertWkt1RoundTrip(2065);
+  }
+
+  @Test
+  public void testWkt1RoundTrip_HotineObliqueMercator_2056() throws 
FactoryException {
+    // Hotine Oblique Mercator fails for PROJ/PROJJSON export but works for 
WKT1
+    assertWkt1RoundTrip(2056);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // WKT2 format round-trip tests
+  // WKT2 goes through proj4sedona for both export and import.
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void testWkt2RoundTrip_Geographic_4326() throws FactoryException {
+    assertWkt2RoundTrip(4326);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_Geographic_NAD83_4269() throws 
FactoryException {
+    assertWkt2RoundTrip(4269);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_TransverseMercator_32617() throws 
FactoryException {
+    assertWkt2RoundTrip(32617);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_PseudoMercator_3857() throws FactoryException {
+    assertWkt2RoundTrip(3857);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_Mercator1SP_3395() throws FactoryException {
+    assertWkt2RoundTrip(3395);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_LambertConformalConic2SP_2154() throws 
FactoryException {
+    assertWkt2RoundTrip(2154);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_LambertAzimuthalEqualArea_Spherical_2163() 
throws FactoryException {
+    assertWkt2RoundTrip(2163);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_AlbersEqualArea_5070() throws FactoryException 
{
+    assertWkt2RoundTrip(5070);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_PolarStereographicB_3031() throws 
FactoryException {
+    assertWkt2RoundTrip(3031);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_LambertAzimuthalEqualArea_3035() throws 
FactoryException {
+    assertWkt2RoundTrip(3035);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_Mercator1SP_Spherical_3785() throws 
FactoryException {
+    assertWkt2RoundTrip(3785);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_EquidistantCylindrical_4087() throws 
FactoryException {
+    assertWkt2RoundTrip(4087);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_TransverseMercator_OSGB_27700() throws 
FactoryException {
+    assertWkt2RoundTrip(27700);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_AlbersEqualArea_Australian_3577() throws 
FactoryException {
+    assertWkt2RoundTrip(3577);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_LambertConformalConic2SP_Vicgrid_3111() throws 
FactoryException {
+    assertWkt2RoundTrip(3111);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_PolarStereographicB_NSIDC_3413() throws 
FactoryException {
+    assertWkt2RoundTrip(3413);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_PolarStereographicA_32661() throws 
FactoryException {
+    assertWkt2RoundTrip(32661);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_LambertAzimuthalEqualArea_EASE_6931() throws 
FactoryException {
+    assertWkt2RoundTrip(6931);
+  }
+
+  @Test
+  public void testWkt2RoundTrip_ObliqueStereographic_28992() throws 
FactoryException {
+    assertWkt2RoundTrip(28992);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // PROJJSON import failures — spherical datums not parseable after round-trip
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void 
testProjJsonRoundTrip_LambertAzimuthalEqualArea_Spherical_2163_importFails()
+      throws FactoryException {
+    assertProjJsonImportFails(2163);
+  }
+
+  @Test
+  public void testProjJsonRoundTrip_Mercator1SP_Spherical_3785() throws 
FactoryException {
+    assertProjJsonRoundTrip(3785);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Export failures — projection types not supported by proj4sedona
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  public void testSetCrs_LambertCylindricalEqualArea_6933() throws 
FactoryException {
+    // EPSG:6933 setCrs works; WKT1 may not contain AUTHORITY so we just 
verify it's parseable
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D result = RasterEditors.setCrs(baseRaster, "EPSG:6933");
+    String wkt1 = RasterAccessors.crs(result, "wkt1");
+    assertNotNull("EPSG:6933 should produce valid WKT1", wkt1);
+    assertTrue("WKT1 should contain PROJCS", wkt1.contains("PROJCS"));
+  }
+
+  @Test
+  public void testExportFails_Krovak_2065() throws FactoryException {
+    assertExportFails(2065);
+  }
+
+  @Test
+  public void testExportFails_HotineObliqueMercator_2056() throws 
FactoryException {
+    assertExportFails(2056);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Helper methods
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Assert a full PROJ format round trip: EPSG → RS_CRS("proj") → RS_SetCRS → 
RS_CRS("proj") →
+   * RS_SetCRS → RS_CRS("proj"). The first export from EPSG may carry extra 
metadata, so we verify
+   * idempotency: the second and third exports (from PROJ string input) must 
be identical.
+   */
+  private void assertProjRoundTrip(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    // First export from EPSG
+    String export1 = RasterAccessors.crs(raster1, "proj");
+    assertNotNull("EPSG:" + epsg + " export to PROJ should not be null", 
export1);
+
+    // Re-import from PROJ string and re-export
+    GridCoverage2D raster2 = RasterEditors.setCrs(baseRaster, export1);
+    String export2 = RasterAccessors.crs(raster2, "proj");
+    assertNotNull("EPSG:" + epsg + " second export to PROJ should not be 
null", export2);
+
+    // Third round-trip to verify idempotency
+    GridCoverage2D raster3 = RasterEditors.setCrs(baseRaster, export2);
+    String export3 = RasterAccessors.crs(raster3, "proj");
+    assertNotNull("EPSG:" + epsg + " third export to PROJ should not be null", 
export3);
+
+    assertEquals(
+        "EPSG:" + epsg + " PROJ string should be stable after round trip 
(export2 == export3)",
+        export2,
+        export3);
+  }
+
+  /**
+   * Assert a full PROJJSON format round trip: EPSG → RS_CRS("projjson") → 
RS_SetCRS →
+   * RS_CRS("projjson") → RS_SetCRS → RS_CRS("projjson"). The first export 
from EPSG may carry extra
+   * metadata (e.g., datum names), so we verify idempotency: the second and 
third exports (from
+   * PROJJSON string input) must be identical.
+   */
+  private void assertProjJsonRoundTrip(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    // First export from EPSG
+    String export1 = RasterAccessors.crs(raster1, "projjson");
+    assertNotNull("EPSG:" + epsg + " export to PROJJSON should not be null", 
export1);
+
+    // Re-import from PROJJSON string and re-export
+    GridCoverage2D raster2 = RasterEditors.setCrs(baseRaster, export1);
+    String export2 = RasterAccessors.crs(raster2, "projjson");
+    assertNotNull("EPSG:" + epsg + " second export to PROJJSON should not be 
null", export2);
+
+    // Third round-trip to verify idempotency
+    GridCoverage2D raster3 = RasterEditors.setCrs(baseRaster, export2);
+    String export3 = RasterAccessors.crs(raster3, "projjson");
+    assertNotNull("EPSG:" + epsg + " third export to PROJJSON should not be 
null", export3);
+
+    assertEquals(
+        "EPSG:" + epsg + " PROJJSON string should be stable after round trip 
(export2 == export3)",
+        export2,
+        export3);
+  }
+
+  /**
+   * Assert a full WKT2 format round trip: EPSG → RS_CRS("wkt2") → RS_SetCRS → 
RS_CRS("wkt2") →
+   * RS_SetCRS → RS_CRS("wkt2"). The first export from EPSG may carry extra 
metadata, so we verify
+   * idempotency: the second and third exports (from WKT2 string input) must 
be identical.
+   */
+  private void assertWkt2RoundTrip(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    // First export from EPSG
+    String export1 = RasterAccessors.crs(raster1, "wkt2");
+    assertNotNull("EPSG:" + epsg + " export to WKT2 should not be null", 
export1);
+
+    // Re-import from WKT2 string and re-export
+    GridCoverage2D raster2 = RasterEditors.setCrs(baseRaster, export1);
+    String export2 = RasterAccessors.crs(raster2, "wkt2");
+    assertNotNull("EPSG:" + epsg + " second export to WKT2 should not be 
null", export2);
+
+    // Third round-trip to verify idempotency
+    GridCoverage2D raster3 = RasterEditors.setCrs(baseRaster, export2);
+    String export3 = RasterAccessors.crs(raster3, "wkt2");
+    assertNotNull("EPSG:" + epsg + " third export to WKT2 should not be null", 
export3);
+
+    assertEquals(
+        "EPSG:" + epsg + " WKT2 string should be stable after round trip 
(export2 == export3)",
+        export2,
+        export3);
+  }
+
+  /**
+   * Assert that PROJJSON export succeeds but re-import fails (spherical datum 
CRS that proj4sedona
+   * can export but GeoTools cannot re-parse).
+   */
+  private void assertProjJsonImportFails(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    // Export should succeed
+    String exported = RasterAccessors.crs(raster1, "projjson");
+    assertNotNull("EPSG:" + epsg + " export to PROJJSON should succeed", 
exported);
+
+    // Re-import should fail
+    Exception thrown =
+        assertThrows(
+            "EPSG:" + epsg + " PROJJSON re-import should fail for spherical 
datum",
+            IllegalArgumentException.class,
+            () -> RasterEditors.setCrs(baseRaster, exported));
+    assertTrue(
+        "Error message should mention CRS parsing",
+        thrown.getMessage().contains("Cannot parse CRS string"));
+  }
+
+  /**
+   * Assert that RS_CRS export fails for projection types not supported by 
proj4sedona. Tests both
+   * "proj" and "projjson" formats.
+   */
+  private void assertExportFails(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    assertThrows(
+        "EPSG:" + epsg + " export to PROJ should fail",
+        Exception.class,
+        () -> RasterAccessors.crs(raster1, "proj"));
+
+    assertThrows(
+        "EPSG:" + epsg + " export to PROJJSON should fail",
+        Exception.class,
+        () -> RasterAccessors.crs(raster1, "projjson"));
+  }
+
+  /**
+   * Assert a full WKT1 format round trip: EPSG → RS_CRS("wkt1") → RS_SetCRS → 
RS_CRS("wkt1"). WKT1
+   * includes AUTHORITY["EPSG","xxxx"] so SRID is always preserved.
+   */
+  private void assertWkt1RoundTrip(int epsg) throws FactoryException {
+    GridCoverage2D baseRaster = RasterConstructors.makeEmptyRaster(1, 4, 4, 0, 
0, 1);
+    GridCoverage2D raster1 = RasterEditors.setCrs(baseRaster, "EPSG:" + epsg);
+
+    // Export to WKT1
+    String exported = RasterAccessors.crs(raster1, "wkt1");
+    assertNotNull("EPSG:" + epsg + " export to WKT1 should not be null", 
exported);
+
+    // Verify AUTHORITY clause is present with correct EPSG code
+    String topAuthority = extractTopLevelAuthority(exported);
+    assertEquals(
+        "EPSG:" + epsg + " WKT1 should contain top-level AUTHORITY",
+        String.valueOf(epsg),
+        topAuthority);
+
+    String projNameBefore = extractWkt1ProjectionName(exported);
+
+    // Re-import and re-export
+    GridCoverage2D raster2 = RasterEditors.setCrs(baseRaster, exported);
+    String reExported = RasterAccessors.crs(raster2, "wkt1");
+    assertNotNull("EPSG:" + epsg + " re-export to WKT1 should not be null", 
reExported);
+
+    String projNameAfter = extractWkt1ProjectionName(reExported);
+    assertEquals(
+        "EPSG:" + epsg + " projection name should be stable after WKT1 round 
trip",
+        projNameBefore,
+        projNameAfter);
+
+    // WKT1 always preserves SRID via AUTHORITY clause
+    int sridAfter = RasterAccessors.srid(raster2);
+    assertEquals(
+        "EPSG:" + epsg + " SRID should be preserved after WKT1 round trip", 
epsg, sridAfter);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Extraction helpers
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Extract PROJECTION name from WKT1, or "Geographic" for GEOGCS without 
PROJECTION. Handles both
+   * PROJECTION["name"] and PROJECTION["name", AUTHORITY[...]].
+   */
+  private String extractWkt1ProjectionName(String wkt1) {
+    Matcher m = WKT1_PROJECTION_PATTERN.matcher(wkt1);
+    if (m.find()) {
+      return m.group(1);
+    }
+    // Geographic CRS has no PROJECTION element
+    if (wkt1.startsWith("GEOGCS[")) {
+      return "Geographic";
+    }
+    fail(
+        "WKT1 should contain PROJECTION or be GEOGCS: "
+            + wkt1.substring(0, Math.min(80, wkt1.length())));
+    return null;
+  }
+
+  /**
+   * Extract the top-level AUTHORITY EPSG code from WKT1. The top-level 
AUTHORITY is the last one in
+   * the string (at the outermost nesting level).
+   */
+  private String extractTopLevelAuthority(String wkt1) {
+    // Find the last AUTHORITY["EPSG","xxxx"] — that's the top-level one
+    Matcher m = WKT1_AUTHORITY_PATTERN.matcher(wkt1);
+    String lastCode = null;
+    while (m.find()) {
+      lastCode = m.group(1);
+    }
+    assertNotNull("WKT1 should contain AUTHORITY[\"EPSG\",\"xxxx\"]", 
lastCode);
+    return lastCode;
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/raster/RasterAccessorsTest.java 
b/common/src/test/java/org/apache/sedona/common/raster/RasterAccessorsTest.java
index 347309cf00..3565cac554 100644
--- 
a/common/src/test/java/org/apache/sedona/common/raster/RasterAccessorsTest.java
+++ 
b/common/src/test/java/org/apache/sedona/common/raster/RasterAccessorsTest.java
@@ -470,4 +470,72 @@ public class RasterAccessorsTest extends RasterTestBase {
     assertEquals(heightInPixel, metadata[11], 1e-9);
     assertEquals(12, metadata.length);
   }
+
+  @Test
+  public void testCrsDefaultFormat() throws FactoryException {
+    // multiBandRaster has WGS84 CRS
+    String crs = RasterAccessors.crs(multiBandRaster);
+    assertNotNull(crs);
+    // Default format is PROJJSON - should be valid JSON containing CRS info
+    assertTrue(crs.contains("\"type\""));
+    assertTrue(crs.contains("WGS 84") || crs.contains("WGS84"));
+  }
+
+  @Test
+  public void testCrsWkt1Format() throws FactoryException {
+    String crs = RasterAccessors.crs(multiBandRaster, "wkt1");
+    assertNotNull(crs);
+    assertTrue(crs.contains("GEOGCS"));
+  }
+
+  @Test
+  public void testCrsWkt2Format() throws FactoryException {
+    String crs = RasterAccessors.crs(multiBandRaster, "wkt2");
+    assertNotNull(crs);
+    // WKT2 uses GEOGCRS or GEODCRS
+    assertTrue(crs.contains("GEOGCRS") || crs.contains("GEODCRS") || 
crs.contains("GeographicCRS"));
+  }
+
+  @Test
+  public void testCrsProjFormat() throws FactoryException {
+    String crs = RasterAccessors.crs(multiBandRaster, "proj");
+    assertNotNull(crs);
+    assertTrue(crs.contains("+proj="));
+  }
+
+  @Test
+  public void testCrsNullForNoCrs() throws FactoryException {
+    // oneBandRaster has no CRS (SRID=0)
+    String crs = RasterAccessors.crs(oneBandRaster);
+    assertNull(crs);
+  }
+
+  @Test
+  public void testCrsWithSetCrsRoundTrip() throws FactoryException {
+    // Set a CRS using a PROJ string, then read it back in various formats
+    String proj = "+proj=utm +zone=10 +datum=WGS84 +units=m +no_defs";
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    GridCoverage2D withCrs = RasterEditors.setCrs(raster, proj);
+
+    // Should be able to retrieve CRS in all formats
+    String projjson = RasterAccessors.crs(withCrs, "projjson");
+    assertNotNull(projjson);
+    assertTrue(projjson.contains("\"type\""));
+
+    String wkt1 = RasterAccessors.crs(withCrs, "wkt1");
+    assertNotNull(wkt1);
+    assertTrue(wkt1.contains("PROJCS") || wkt1.contains("GEOGCS"));
+
+    String wkt2 = RasterAccessors.crs(withCrs, "wkt2");
+    assertNotNull(wkt2);
+
+    String projStr = RasterAccessors.crs(withCrs, "proj");
+    assertNotNull(projStr);
+    assertTrue(projStr.contains("+proj="));
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testCrsInvalidFormat() throws FactoryException {
+    RasterAccessors.crs(multiBandRaster, "invalid_format");
+  }
 }
diff --git 
a/common/src/test/java/org/apache/sedona/common/raster/RasterEditorsTest.java 
b/common/src/test/java/org/apache/sedona/common/raster/RasterEditorsTest.java
index 8022ed734c..02f4af1cf0 100644
--- 
a/common/src/test/java/org/apache/sedona/common/raster/RasterEditorsTest.java
+++ 
b/common/src/test/java/org/apache/sedona/common/raster/RasterEditorsTest.java
@@ -4210,4 +4210,150 @@ public class RasterEditorsTest extends RasterTestBase {
       }
     }
   }
+
+  @Test
+  public void testSetCrsWithEpsgCode() throws FactoryException {
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    assertEquals(0, RasterAccessors.srid(raster));
+
+    GridCoverage2D result = RasterEditors.setCrs(raster, "EPSG:4326");
+    assertEquals(4326, RasterAccessors.srid(result));
+  }
+
+  @Test
+  public void testSetCrsWithWkt1() throws FactoryException {
+    String wkt1 =
+        "GEOGCS[\"WGS 84\","
+            + "DATUM[\"WGS_1984\","
+            + "SPHEROID[\"WGS 84\",6378137,298.257223563]],"
+            + "PRIMEM[\"Greenwich\",0],"
+            + "UNIT[\"degree\",0.0174532925199433],"
+            + "AUTHORITY[\"EPSG\",\"4326\"]]";
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    GridCoverage2D result = RasterEditors.setCrs(raster, wkt1);
+    assertEquals(4326, RasterAccessors.srid(result));
+  }
+
+  @Test
+  public void testSetCrsWithProjString() throws FactoryException {
+    String proj = "+proj=longlat +datum=WGS84 +no_defs";
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    GridCoverage2D result = RasterEditors.setCrs(raster, proj);
+    assertEquals(4326, RasterAccessors.srid(result));
+  }
+
+  @Test
+  public void testSetCrsWithProjJson() throws FactoryException {
+    // PROJJSON for EPSG:3857
+    String projjson =
+        "{\"$schema\":\"https://proj.org/schemas/v0.7/projjson.schema.json\",";
+            + "\"type\":\"ProjectedCRS\","
+            + "\"name\":\"WGS 84 / Pseudo-Mercator\","
+            + "\"base_crs\":{\"name\":\"WGS 84\","
+            + "\"datum\":{\"type\":\"GeodeticReferenceFrame\","
+            + "\"name\":\"World Geodetic System 1984\","
+            + "\"ellipsoid\":{\"name\":\"WGS 84\","
+            + "\"semi_major_axis\":6378137,"
+            + "\"inverse_flattening\":298.257223563}},"
+            + "\"coordinate_system\":{\"subtype\":\"ellipsoidal\","
+            + "\"axis\":[{\"name\":\"Geodetic 
latitude\",\"abbreviation\":\"Lat\","
+            + "\"direction\":\"north\",\"unit\":\"degree\"},"
+            + "{\"name\":\"Geodetic longitude\",\"abbreviation\":\"Lon\","
+            + "\"direction\":\"east\",\"unit\":\"degree\"}]}},"
+            + "\"conversion\":{\"name\":\"Popular Visualisation 
Pseudo-Mercator\","
+            + "\"method\":{\"name\":\"Popular Visualisation Pseudo Mercator\","
+            + "\"id\":{\"authority\":\"EPSG\",\"code\":1024}},"
+            + "\"parameters\":[{\"name\":\"Latitude of natural 
origin\",\"value\":0,"
+            + 
"\"unit\":\"degree\",\"id\":{\"authority\":\"EPSG\",\"code\":8801}},"
+            + "{\"name\":\"Longitude of natural origin\",\"value\":0,"
+            + 
"\"unit\":\"degree\",\"id\":{\"authority\":\"EPSG\",\"code\":8802}},"
+            + "{\"name\":\"False easting\",\"value\":0,"
+            + 
"\"unit\":\"metre\",\"id\":{\"authority\":\"EPSG\",\"code\":8806}},"
+            + "{\"name\":\"False northing\",\"value\":0,"
+            + 
"\"unit\":\"metre\",\"id\":{\"authority\":\"EPSG\",\"code\":8807}}]},"
+            + "\"coordinate_system\":{\"subtype\":\"Cartesian\","
+            + "\"axis\":[{\"name\":\"Easting\",\"abbreviation\":\"X\","
+            + "\"direction\":\"east\",\"unit\":\"metre\"},"
+            + "{\"name\":\"Northing\",\"abbreviation\":\"Y\","
+            + "\"direction\":\"north\",\"unit\":\"metre\"}]},"
+            + "\"id\":{\"authority\":\"EPSG\",\"code\":3857}}";
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    GridCoverage2D result = RasterEditors.setCrs(raster, projjson);
+    assertEquals(3857, RasterAccessors.srid(result));
+  }
+
+  @Test
+  public void testSetCrsWithCustomProj() throws FactoryException {
+    // Custom Lambert Conformal Conic - no EPSG code
+    String proj =
+        "+proj=lcc +lat_1=25 +lat_2=60 +lat_0=42.5 +lon_0=-100 "
+            + "+x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs";
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    GridCoverage2D result = RasterEditors.setCrs(raster, proj);
+    // Custom CRS has no EPSG code, SRID should be 0
+    assertEquals(0, RasterAccessors.srid(result));
+    // But the CRS should be valid and contain the projection info
+    String crsWkt = RasterAccessors.crs(result, "wkt1");
+    Assert.assertTrue(crsWkt.contains("Lambert_Conformal_Conic"));
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testSetCrsWithInvalidString() throws FactoryException {
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+    RasterEditors.setCrs(raster, "NOT_A_VALID_CRS");
+  }
+
+  /**
+   * Comprehensive test: verify that RS_SetCRS works with every projection 
type that proj4sedona
+   * supports. Each projection short code is tested with appropriate 
parameters. proj4sedona outputs
+   * WKT1 with projection names that may differ from GeoTools conventions 
(e.g. "Azimuthal
+   * Equidistant" vs "Azimuthal_Equidistant"), and may include parameters not 
expected by GeoTools
+   * (e.g. standard_parallel_1 for Transverse Mercator). The normalization and 
parameter-stripping
+   * logic in parseCrsString handles both cases.
+   */
+  @Test
+  public void testSetCrsWithRepresentativeProj4SedonaProjections() throws 
FactoryException {
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+
+    // A representative set of projection short codes supported by 
proj4sedona, each with
+    // appropriate parameters.
+    // Format: {shortCode, projString}
+    String[][] projConfigs = {
+      {
+        "aea",
+        "+proj=aea +lat_0=0 +lon_0=0 +lat_1=30 +lat_2=60 +x_0=0 +y_0=0 
+datum=WGS84 +units=m +no_defs"
+      },
+      {"aeqd", "+proj=aeqd +lat_0=0 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {"cea", "+proj=cea +lat_ts=30 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {"eqc", "+proj=eqc +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {
+        "eqdc",
+        "+proj=eqdc +lat_0=0 +lon_0=0 +lat_1=30 +lat_2=60 +x_0=0 +y_0=0 
+datum=WGS84 +units=m +no_defs"
+      },
+      {
+        "etmerc", "+proj=etmerc +lat_0=0 +lon_0=0 +k=1 +x_0=0 +y_0=0 
+datum=WGS84 +units=m +no_defs"
+      },
+      {"laea", "+proj=laea +lat_0=0 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {
+        "lcc",
+        "+proj=lcc +lat_0=0 +lon_0=0 +lat_1=30 +lat_2=60 +x_0=0 +y_0=0 
+datum=WGS84 +units=m +no_defs"
+      },
+      {"merc", "+proj=merc +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {"moll", "+proj=moll +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m 
+no_defs"},
+      {"robin", "+proj=robin +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m 
+no_defs"},
+      {"sinu", "+proj=sinu +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m 
+no_defs"},
+      {"stere", "+proj=stere +lat_0=90 +lon_0=0 +k=1 +x_0=0 +y_0=0 
+datum=WGS84 +units=m +no_defs"},
+      {"tmerc", "+proj=tmerc +lat_0=0 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 
+units=m +no_defs"},
+      {"utm", "+proj=utm +zone=17 +datum=WGS84 +units=m +no_defs"},
+    };
+
+    for (String[] config : projConfigs) {
+      String code = config[0];
+      String projStr = config[1];
+      GridCoverage2D result = RasterEditors.setCrs(raster, projStr);
+      String wkt1 = RasterAccessors.crs(result, "wkt1");
+      Assert.assertNotNull("setCrs should produce a valid CRS for +proj=" + 
code, wkt1);
+      Assert.assertTrue("WKT1 should contain PROJCS for +proj=" + code, 
wkt1.contains("PROJCS"));
+    }
+  }
 }
diff --git a/docs/api/sql/Raster-Functions.md b/docs/api/sql/Raster-Functions.md
index b7ea6e46b9..bddf209a21 100644
--- a/docs/api/sql/Raster-Functions.md
+++ b/docs/api/sql/Raster-Functions.md
@@ -122,10 +122,12 @@ These functions perform operations on raster objects.
 | [RS_SetBandNoDataValue](Raster-Operators/RS_SetBandNoDataValue.md) | This 
sets the no data value for a specified band in the raster. If the band index is 
not provided, band 1 is assumed by default. Passing a `null` value for 
`noDataValue` will remove the no data val... | v1.5.0 |
 | [RS_SetGeoReference](Raster-Operators/RS_SetGeoReference.md) | Sets the 
Georeference information of an object in a single call. Accepts inputs in 
`GDAL` and `ESRI` format. Default format is `GDAL`. If all 6 parameters are not 
provided then will return null. | v1.5.0 |
 | [RS_SetPixelType](Raster-Operators/RS_SetPixelType.md) | Returns a modified 
raster with the desired pixel data type. | v1.6.0 |
+| [RS_SetCRS](Raster-Operators/RS_SetCRS.md) | Sets the coordinate reference 
system (CRS) of the raster using a CRS definition string. Accepts EPSG codes, 
WKT1, WKT2, PROJ strings, and PROJJSON. | v1.9.0 |
 | [RS_SetSRID](Raster-Operators/RS_SetSRID.md) | Sets the spatial reference 
system identifier (SRID) of the raster geometry. | v1.4.1 |
 | [RS_SetValue](Raster-Operators/RS_SetValue.md) | Returns a raster by 
replacing the value of pixel specified by `colX` and `rowY`. | v1.5.0 |
 | [RS_SetValues](Raster-Operators/RS_SetValues.md) | Returns a raster by 
replacing the values of pixels in a specified rectangular region. The top left 
corner of the region is defined by the `colX` and `rowY` coordinates. The 
`width` and `height` par... | v1.5.0 |
 | [RS_SRID](Raster-Operators/RS_SRID.md) | Returns the spatial reference 
system identifier (SRID) of the raster geometry. | v1.4.1 |
+| [RS_CRS](Raster-Operators/RS_CRS.md) | Returns the coordinate reference 
system (CRS) of the raster as a string in the specified format (projjson, wkt2, 
wkt1, proj). Defaults to PROJJSON. | v1.9.0 |
 | [RS_Union](Raster-Operators/RS_Union.md) | Returns a combined multi-band 
raster from 2 or more input Rasters. The order of bands in the resultant raster 
will be in the order of the input rasters. For example if `RS_Union` is called 
on two 2... | v1.6.0 |
 | [RS_Value](Raster-Operators/RS_Value.md) | Returns the value at the given 
point in the raster. If no band number is specified it defaults to 1. | v1.4.0 |
 | [RS_Values](Raster-Operators/RS_Values.md) | Returns the values at the given 
points or grid coordinates in the raster. If no band number is specified it 
defaults to 1. | v1.4.0 |
diff --git a/docs/api/sql/Raster-Operators/RS_CRS.md 
b/docs/api/sql/Raster-Operators/RS_CRS.md
new file mode 100644
index 0000000000..7e719f2c88
--- /dev/null
+++ b/docs/api/sql/Raster-Operators/RS_CRS.md
@@ -0,0 +1,101 @@
+<!--
+ 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.
+ -->
+
+# RS_CRS
+
+Introduction: Returns the coordinate reference system (CRS) of a raster as a 
string in the specified format. If no format is specified, the CRS is returned 
in PROJJSON format. Returns null if the raster has no CRS defined.
+
+Format:
+
+```
+RS_CRS (raster: Raster)
+```
+
+```
+RS_CRS (raster: Raster, format: String)
+```
+
+Since: `v1.9.0`
+
+## Supported output formats
+
+| Format | Description |
+| :--- | :--- |
+| `'projjson'` | PROJJSON format (default). Modern, machine-readable JSON 
representation. |
+| `'wkt2'` | Well-Known Text 2 (ISO 19162). Modern standard CRS 
representation. |
+| `'wkt1'` | Well-Known Text 1 (OGC 01-009). Legacy format, widely supported. |
+| `'proj'` | PROJ string format. Compact, human-readable representation. |
+
+## SQL Examples
+
+Getting CRS in default PROJJSON format:
+
+```sql
+SELECT RS_CRS(raster) FROM raster_table
+```
+
+Output:
+
+```json
+{
+  "$schema": "https://proj.org/schemas/v0.7/projjson.schema.json";,
+  "type": "GeographicCRS",
+  "name": "WGS 84",
+  ...
+}
+```
+
+Getting CRS in WKT1 format:
+
+```sql
+SELECT RS_CRS(raster, 'wkt1') FROM raster_table
+```
+
+Output:
+
+```
+GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 
84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]
+```
+
+Getting CRS in PROJ string format:
+
+```sql
+SELECT RS_CRS(raster, 'proj') FROM raster_table
+```
+
+Output:
+
+```
++proj=longlat +datum=WGS84 +no_defs +type=crs
+```
+
+Getting CRS in WKT2 format:
+
+```sql
+SELECT RS_CRS(raster, 'wkt2') FROM raster_table
+```
+
+## Limitations
+
+The `wkt2`, `proj`, and `projjson` output formats are generated by 
[proj4sedona](https://github.com/jiayuasu/proj4sedona) from the raster's 
internal WKT1 CRS. This conversion may cause the following limitations:
+
+- **Unsupported projection types**: Some projection types (e.g., Krovak, 
Hotine Oblique Mercator) cannot be exported to `wkt2`, `proj`, or `projjson` 
formats and will throw an error. Use `'wkt1'` format for these.
+
+!!!note
+    `RS_CRS` returns null only when the raster has no CRS defined. Note that 
`RS_SRID` may return `0` either when no CRS is defined or when a custom 
(non-EPSG) CRS has been set via `RS_SetCRS`, so `RS_SRID = 0` does not always 
mean "no CRS". To test for a missing CRS, use `RS_CRS(raster) IS NULL`. The 
`wkt1` format always produces a lossless representation of the internally 
stored CRS.
diff --git a/docs/api/sql/Raster-Operators/RS_SRID.md 
b/docs/api/sql/Raster-Operators/RS_SRID.md
index 7820828e2e..8d79b94b0c 100644
--- a/docs/api/sql/Raster-Operators/RS_SRID.md
+++ b/docs/api/sql/Raster-Operators/RS_SRID.md
@@ -19,7 +19,7 @@
 
 # RS_SRID
 
-Introduction: Returns the spatial reference system identifier (SRID) of the 
raster geometry.
+Introduction: Returns the spatial reference system identifier (SRID) of the 
raster geometry. Returns 0 if the raster has no CRS defined or if the CRS is a 
custom (non-EPSG) coordinate reference system. To retrieve the full CRS 
definition for custom CRS, use [RS_CRS](RS_CRS.md).
 
 Format: `RS_SRID (raster: Raster)`
 
diff --git a/docs/api/sql/Raster-Operators/RS_SetCRS.md 
b/docs/api/sql/Raster-Operators/RS_SetCRS.md
new file mode 100644
index 0000000000..98d219e02e
--- /dev/null
+++ b/docs/api/sql/Raster-Operators/RS_SetCRS.md
@@ -0,0 +1,68 @@
+<!--
+ 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.
+ -->
+
+# RS_SetCRS
+
+Introduction: Sets the coordinate reference system (CRS) of a raster using a 
CRS definition string. Unlike `RS_SetSRID` which only accepts integer EPSG 
codes, `RS_SetCRS` accepts CRS definitions in multiple formats including EPSG 
codes, WKT1, WKT2, PROJ strings, and PROJJSON. This function does not 
reproject/transform the raster data — it only sets the CRS metadata.
+
+Format: `RS_SetCRS (raster: Raster, crsString: String)`
+
+Since: `v1.9.0`
+
+## Supported CRS formats
+
+| Format | Example |
+| :--- | :--- |
+| EPSG code | `'EPSG:4326'` |
+| WKT1 | `'GEOGCS["WGS 84", DATUM["WGS_1984", ...], ...]'` |
+| WKT2 | `'GEOGCRS["WGS 84", DATUM["World Geodetic System 1984", ...], ...]'` |
+| PROJ string | `'+proj=longlat +datum=WGS84 +no_defs'` |
+| PROJJSON | `'{"type": "GeographicCRS", "name": "WGS 84", ...}'` |
+
+## SQL Examples
+
+Setting CRS with an EPSG code:
+
+```sql
+SELECT RS_SetCRS(raster, 'EPSG:4326') FROM raster_table
+```
+
+Setting CRS with a PROJ string (useful for custom projections):
+
+```sql
+SELECT RS_SetCRS(raster, '+proj=lcc +lat_1=25 +lat_2=60 +lat_0=42.5 
+lon_0=-100 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs')
+FROM raster_table
+```
+
+Setting CRS with a WKT1 string:
+
+```sql
+SELECT RS_SetCRS(raster, 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 
84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]')
+FROM raster_table
+```
+
+## Limitations
+
+Internally, Sedona stores raster CRS in WKT1 format (via GeoTools). When you 
provide a CRS in WKT2, PROJ, or PROJJSON format, it is converted to WKT1 using 
[proj4sedona](https://github.com/jiayuasu/proj4sedona). This conversion may 
cause the following limitations:
+
+- **SRID not preserved for projected CRS**: When importing PROJ or PROJJSON 
strings, the EPSG SRID is often lost for projected coordinate systems. Only 
geographic CRS (e.g., EPSG:4326), Web Mercator (EPSG:3857), and UTM zones 
reliably preserve their SRID. Use `RS_SetSRID` after `RS_SetCRS` if you need to 
set a specific SRID.
+- **Unsupported projection types**: Some projection types (e.g., Krovak, 
Hotine Oblique Mercator) are not supported by proj4sedona and will fail for 
WKT2, PROJ, and PROJJSON formats. Use `'EPSG:xxxx'` or WKT1 for these.
+
+!!!note
+    For the most reliable results, use `'EPSG:xxxx'` format when your CRS has 
a known EPSG code. WKT1 input is also lossless since it is stored natively. 
WKT2, PROJ, and PROJJSON inputs undergo conversion and may experience the 
limitations above.
diff --git a/pom.xml b/pom.xml
index 6a113fabd1..3f5434a906 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,7 +96,7 @@
         
<scala-collection-compat.version>2.5.0</scala-collection-compat.version>
         <geoglib.version>1.52</geoglib.version>
         <caffeine.version>2.9.2</caffeine.version>
-        <proj4sedona.version>0.0.6</proj4sedona.version>
+        <proj4sedona.version>0.0.8</proj4sedona.version>
 
         <geotools.scope>provided</geotools.scope>
         <!-- Because it's not in Maven central, make it provided by default -->
diff --git 
a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala 
b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
index 3f8ebf193e..815d2c6eb2 100644
--- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
+++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
@@ -303,12 +303,14 @@ object Catalog extends AbstractCatalog with Logging {
     function[RS_NumBands](),
     function[RS_Metadata](),
     function[RS_SetSRID](),
+    function[RS_SetCRS](),
     function[RS_SetGeoReference](),
     function[RS_SetBandNoDataValue](),
     function[RS_SetPixelType](),
     function[RS_SetValues](),
     function[RS_SetValue](),
     function[RS_SRID](),
+    function[RS_CRS](),
     function[RS_Value](1),
     function[RS_Values](1),
     function[RS_Intersects](),
diff --git 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterAccessors.scala
 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterAccessors.scala
index ed3cc5327f..7d75bc137a 100644
--- 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterAccessors.scala
+++ 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterAccessors.scala
@@ -38,6 +38,15 @@ private[apache] case class RS_SRID(inputExpressions: 
Seq[Expression])
   }
 }
 
+private[apache] case class RS_CRS(inputExpressions: Seq[Expression])
+    extends InferredExpression(
+      inferrableFunction1(RasterAccessors.crs),
+      inferrableFunction2(RasterAccessors.crs)) {
+  protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = 
{
+    copy(inputExpressions = newChildren)
+  }
+}
+
 private[apache] case class RS_Width(inputExpressions: Seq[Expression])
     extends InferredExpression(RasterAccessors.getWidth _) {
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = 
{
diff --git 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterEditors.scala
 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterEditors.scala
index 96855548ee..2394a49375 100644
--- 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterEditors.scala
+++ 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/raster/RasterEditors.scala
@@ -31,6 +31,13 @@ private[apache] case class RS_SetSRID(inputExpressions: 
Seq[Expression])
   }
 }
 
+private[apache] case class RS_SetCRS(inputExpressions: Seq[Expression])
+    extends InferredExpression(RasterEditors.setCrs _) {
+  protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = 
{
+    copy(inputExpressions = newChildren)
+  }
+}
+
 private[apache] case class RS_SetGeoReference(inputExpressions: 
Seq[Expression])
     extends InferredExpression(
       inferrableFunction2(RasterEditors.setGeoReference),
diff --git 
a/spark/common/src/test/scala/org/apache/sedona/sql/rasteralgebraTest.scala 
b/spark/common/src/test/scala/org/apache/sedona/sql/rasteralgebraTest.scala
index fb5db1993a..e935acfa56 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/rasteralgebraTest.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/rasteralgebraTest.scala
@@ -472,6 +472,77 @@ class rasteralgebraTest extends TestBaseScala with 
BeforeAndAfter with GivenWhen
       assert(metadata(9) == metadata_4326(9))
     }
 
+    it("Passed RS_SetCRS should handle null values") {
+      val result = sparkSession.sql("select RS_SetCRS(null, 
null)").first().get(0)
+      assert(result == null)
+    }
+
+    it("Passed RS_SetCRS with EPSG code string") {
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      val result = df
+        .selectExpr("RS_SRID(RS_SetCRS(RS_FromGeoTiff(content), 'EPSG:4326'))")
+        .first()
+        .getInt(0)
+      assert(result == 4326)
+    }
+
+    it("Passed RS_SetCRS with PROJ string") {
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      val result = df
+        .selectExpr(
+          "RS_SRID(RS_SetCRS(RS_FromGeoTiff(content), '+proj=longlat 
+datum=WGS84 +no_defs'))")
+        .first()
+        .getInt(0)
+      assert(result == 4326)
+    }
+
+    it("Passed RS_SetCRS with WKT1 string") {
+      val wkt1 =
+        "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 
84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0],UNIT[\"degree\",0.0174532925199433]]"
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      // WKT1 without AUTHORITY clause: RS_SRID returns 0, but RS_CRS still 
works
+      val srid =
+        df.selectExpr(s"RS_SRID(RS_SetCRS(RS_FromGeoTiff(content), 
'${wkt1}'))").first().getInt(0)
+      assert(srid == 0)
+      val crs =
+        df.selectExpr(s"RS_CRS(RS_SetCRS(RS_FromGeoTiff(content), '${wkt1}'), 
'wkt1')")
+          .first()
+          .getString(0)
+      assert(crs != null && crs.contains("WGS"))
+    }
+
+    it("Passed RS_CRS should handle null values") {
+      val result = sparkSession.sql("select RS_CRS(null)").first().get(0)
+      assert(result == null)
+    }
+
+    it("Passed RS_CRS returns PROJJSON by default") {
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      val result = 
df.selectExpr("RS_CRS(RS_FromGeoTiff(content))").first().getString(0)
+      assert(result != null)
+      assert(result.contains("\"type\""))
+    }
+
+    it("Passed RS_CRS with wkt1 format") {
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      val result = df.selectExpr("RS_CRS(RS_FromGeoTiff(content), 
'wkt1')").first().getString(0)
+      assert(result != null)
+      assert(result.contains("PROJCS"))
+    }
+
+    it("Passed RS_CRS with proj format") {
+      val df = sparkSession.read.format("binaryFile").load(resourceFolder + 
"raster/test1.tiff")
+      val result = df.selectExpr("RS_CRS(RS_FromGeoTiff(content), 
'proj')").first().getString(0)
+      assert(result != null)
+      assert(result.contains("+proj="))
+    }
+
+    it("Passed RS_CRS returns null for raster without CRS") {
+      val result =
+        sparkSession.sql("select RS_CRS(RS_MakeEmptyRaster(1, 10, 10, 0, 0, 
1))").first().get(0)
+      assert(result == null)
+    }
+
     it("Passed RS_SetGeoReference should handle null values") {
       val result = sparkSession.sql("select RS_SetGeoReference(null, 
null)").first().get(0)
       assertNull(result)

Reply via email to