jiayuasu commented on code in PR #2677:
URL: https://github.com/apache/sedona/pull/2677#discussion_r2871164678


##########
common/src/main/java/org/apache/sedona/common/raster/CrsNormalization.java:
##########
@@ -0,0 +1,242 @@
+/*
+ * 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.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().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);

Review Comment:
   Fixed



##########
common/src/test/java/org/apache/sedona/common/raster/RasterEditorsTest.java:
##########
@@ -4210,4 +4210,149 @@ private void verifyReprojectMatchResult(
       }
     }
   }
+
+  @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 testSetCrsWithAllProj4SedonaProjections() throws 
FactoryException {
+    GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 10, 10, 0, 
0, 1);
+
+    // All projection short codes supported by proj4sedona, each with 
appropriate parameters.

Review Comment:
   Fixed



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to