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

bchapuis pushed a commit to branch raster-processing
in repository https://gitbox.apache.org/repos/asf/incubator-baremaps.git

commit 7f7199d3b17cf73478e92ccd65f5ee251cc50424
Author: Bertil Chapuis <[email protected]>
AuthorDate: Tue Jul 23 15:46:01 2024 +0200

    Refactor the code and improve javadoc
---
 .../org/apache/baremaps/cli/raster/HillShade.java  |   9 +-
 baremaps-raster/pom.xml                            |   5 +-
 .../org/apache/baremaps/raster/ElevationUtils.java | 112 ++++++++++
 .../org/apache/baremaps/raster/ImageUtils.java     |  44 ----
 .../apache/baremaps/raster/contour/IsoLines.java   | 115 -----------
 .../baremaps/raster/elevation/HillShade.java       | 150 ++++++++++++++
 .../apache/baremaps/raster/elevation/IsoLines.java | 211 +++++++++++++++++++
 .../baremaps/raster/hillshade/HillShade.java       | 131 ------------
 .../baremaps/raster/contour/IsoLineRenderer.java   |  81 --------
 .../raster/elevation/HillShadeRenderer.java        | 160 +++++++++++++++
 .../baremaps/raster/elevation/HillShadeTest.java   | 228 +++++++++++++++++++++
 .../raster/elevation/IsoLinesRenderer.java         | 132 ++++++++++++
 .../{contour => elevation}/IsoLinesTest.java       |  21 +-
 .../raster/hillshade/HillShadeRenderer.java        |  59 ------
 .../baremaps/raster/hillshade/HillShadeTest.java   |  48 -----
 .../src/main/resources/raster/favicon.ico          | Bin 0 -> 15086 bytes
 .../src/main/resources/raster/hillshade.html       |  57 ++++++
 pom.xml                                            |   4 +-
 18 files changed, 1078 insertions(+), 489 deletions(-)

diff --git 
a/baremaps-cli/src/main/java/org/apache/baremaps/cli/raster/HillShade.java 
b/baremaps-cli/src/main/java/org/apache/baremaps/cli/raster/HillShade.java
index 7defd18c..4125e671 100644
--- a/baremaps-cli/src/main/java/org/apache/baremaps/cli/raster/HillShade.java
+++ b/baremaps-cli/src/main/java/org/apache/baremaps/cli/raster/HillShade.java
@@ -41,7 +41,7 @@ import java.util.concurrent.Callable;
 import java.util.function.Function;
 import java.util.function.Supplier;
 import javax.imageio.ImageIO;
-import org.apache.baremaps.raster.ImageUtils;
+import org.apache.baremaps.raster.ElevationUtils;
 import org.apache.baremaps.tilestore.TileCoord;
 import org.apache.baremaps.tilestore.TileStore;
 import org.apache.baremaps.tilestore.TileStoreException;
@@ -104,7 +104,8 @@ public class HillShade implements Callable<Integer> {
   public static class HillShadeTileStore implements TileStore {
 
     // private String url = 
"https://s3.amazonaws.com/elevation-tiles-prod/geotiff/{z}/{x}/{y}.tif";;
-    private String url = 
"https://demotiles.maplibre.org/terrain-tiles/{z}/{x}/{y}.png";;
+    // private String url = 
"https://demotiles.maplibre.org/terrain-tiles/{z}/{x}/{y}.png";;
+    private String url = 
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png";;
 
     private final LoadingCache<TileCoord, BufferedImage> cache = 
Caffeine.newBuilder()
         .maximumSize(1000)
@@ -171,8 +172,8 @@ public class HillShade implements Callable<Integer> {
             image.getWidth() + 2,
             image.getHeight() + 2);
 
-        var grid = ImageUtils.grid(buffer);
-        var hillshade = 
org.apache.baremaps.raster.hillshade.HillShade.hillShade(grid,
+        var grid = ElevationUtils.imageToGrid(buffer);
+        var hillshade = 
org.apache.baremaps.raster.elevation.HillShade.hillShade(grid,
             buffer.getWidth(), buffer.getHeight(), 45, 315);
 
         // Create an output image
diff --git a/baremaps-raster/pom.xml b/baremaps-raster/pom.xml
index 6499ffd5..6ee64ad5 100644
--- a/baremaps-raster/pom.xml
+++ b/baremaps-raster/pom.xml
@@ -11,7 +11,10 @@
     <dependency>
       <groupId>com.twelvemonkeys.imageio</groupId>
       <artifactId>imageio-tiff</artifactId>
-      <version>3.11.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.locationtech.jts</groupId>
+      <artifactId>jts-core</artifactId>
     </dependency>
   </dependencies>
 </project>
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/ElevationUtils.java 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/ElevationUtils.java
new file mode 100644
index 00000000..f5c7c28c
--- /dev/null
+++ 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/ElevationUtils.java
@@ -0,0 +1,112 @@
+/*
+ * 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.baremaps.raster;
+
+import java.awt.image.BufferedImage;
+
+/**
+ * Provides utility methods for processing raster images, particularly for 
elevation data.
+ */
+public class ElevationUtils {
+
+  private static final double ELEVATION_SCALE = 256.0 * 256.0;
+  private static final double ELEVATION_OFFSET = 10000.0;
+
+  private ElevationUtils() {
+    // Private constructor to prevent instantiation
+  }
+
+  /**
+   * Converts a BufferedImage to a grid of elevation values.
+   *
+   * @param image The input BufferedImage
+   * @return A double array representing the elevation grid
+   */
+  public static double[] imageToGrid(BufferedImage image) {
+    validateImage(image);
+    int width = image.getWidth();
+    int height = image.getHeight();
+    double[] grid = new double[width * height];
+
+    for (int y = 0; y < height; y++) {
+      for (int x = 0; x < width; x++) {
+        grid[y * width + x] = pixelToElevation(image.getRGB(x, y));
+      }
+    }
+
+    return grid;
+  }
+
+  /**
+   * Converts a grid of elevation values to a BufferedImage.
+   *
+   * @param grid The input elevation grid
+   * @param width The width of the grid
+   * @param height The height of the grid
+   * @return A BufferedImage representing the elevation data
+   */
+  public static BufferedImage gridToImage(double[] grid, int width, int 
height) {
+    validateGrid(grid, width, height);
+    BufferedImage image = new BufferedImage(width, height, 
BufferedImage.TYPE_INT_RGB);
+
+    for (int y = 0; y < height; y++) {
+      for (int x = 0; x < width; x++) {
+        image.setRGB(x, y, elevationToPixel(grid[y * width + x]));
+      }
+    }
+
+    return image;
+  }
+
+  private static double pixelToElevation(int rgb) {
+    int r = (rgb >> 16) & 0xFF;
+    int g = (rgb >> 8) & 0xFF;
+    int b = rgb & 0xFF;
+    return (r * ELEVATION_SCALE + g * 256.0 + b) / 10.0 - ELEVATION_OFFSET;
+  }
+
+  private static int elevationToPixel(double elevation) {
+    int value = (int) ((elevation + ELEVATION_OFFSET) * 10.0);
+    int r = (value >> 16) & 0xFF;
+    int g = (value >> 8) & 0xFF;
+    int b = value & 0xFF;
+    return (r << 16) | (g << 8) | b;
+  }
+
+  private static void validateImage(BufferedImage image) {
+    if (image == null) {
+      throw new IllegalArgumentException("Input image cannot be null");
+    }
+    if (image.getWidth() <= 0 || image.getHeight() <= 0) {
+      throw new IllegalArgumentException("Image dimensions must be positive");
+    }
+  }
+
+  private static void validateGrid(double[] grid, int width, int height) {
+    if (grid == null || grid.length == 0) {
+      throw new IllegalArgumentException("Grid array cannot be null or empty");
+    }
+    if (width <= 0 || height <= 0) {
+      throw new IllegalArgumentException("Width and height must be positive");
+    }
+    if (grid.length != width * height) {
+      throw new IllegalArgumentException("Grid array length does not match 
width * height");
+    }
+  }
+
+}
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/ImageUtils.java 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/ImageUtils.java
deleted file mode 100644
index 854ff5c1..00000000
--- a/baremaps-raster/src/main/java/org/apache/baremaps/raster/ImageUtils.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * 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.baremaps.raster;
-
-import java.awt.image.BufferedImage;
-
-public class ImageUtils {
-
-
-  public static double[] grid(BufferedImage image) {
-    int gridSize = image.getWidth();
-    double[] terrain = new double[gridSize * gridSize];
-
-    int tileSize = image.getWidth();
-
-    // decode terrain values
-    for (int y = 0; y < tileSize; y++) {
-      for (int x = 0; x < tileSize; x++) {
-        int r = (image.getRGB(x, y) >> 16) & 0xFF;
-        int g = (image.getRGB(x, y) >> 8) & 0xFF;
-        int b = image.getRGB(x, y) & 0xFF;
-        terrain[y * gridSize + x] = (r * 256.0 * 256.0 + g * 256.0 + b) / 10.0 
- 10000.0;
-      }
-    }
-
-    return terrain;
-  }
-
-}
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/contour/IsoLines.java
 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/contour/IsoLines.java
deleted file mode 100644
index e2f7ba89..00000000
--- 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/contour/IsoLines.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * 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.baremaps.raster.contour;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class IsoLines {
-
-  public record Point(double x, double y) {
-  }
-
-  public record IsoLine(List<Point> points) {
-  }
-
-  public static List<IsoLine> isoLines(double[] grid, int gridSize, double 
level) {
-    List<IsoLine> isoLines = new ArrayList<>();
-    for (int y = 0; y < gridSize - 1; y++) {
-      for (int x = 0; x < gridSize - 1; x++) {
-        int index = (grid[y * (gridSize + 1) + x] > level ? 1 : 0) |
-            (grid[y * (gridSize + 1) + (x + 1)] > level ? 2 : 0) |
-            (grid[(y + 1) * (gridSize + 1) + (x + 1)] > level ? 4 : 0) |
-            (grid[(y + 1) * (gridSize + 1) + x] > level ? 8 : 0);
-        List<Point> points = new ArrayList<>();
-        switch (index) {
-          case 1:
-          case 14:
-            points.add(interpolate(x, y, x, y + 1, grid, gridSize + 1, level));
-            points.add(interpolate(x, y, x + 1, y, grid, gridSize + 1, level));
-            break;
-          case 2:
-          case 13:
-            points.add(interpolate(x + 1, y, x, y, grid, gridSize + 1, level));
-            points.add(interpolate(x + 1, y, x + 1, y + 1, grid, gridSize + 1, 
level));
-            break;
-          case 3:
-          case 12:
-            points.add(interpolate(x, y, x, y + 1, grid, gridSize + 1, level));
-            points.add(interpolate(x + 1, y, x + 1, y + 1, grid, gridSize + 1, 
level));
-            break;
-          case 4:
-          case 11:
-            points.add(interpolate(x + 1, y + 1, x + 1, y, grid, gridSize + 1, 
level));
-            points.add(interpolate(x + 1, y + 1, x, y + 1, grid, gridSize + 1, 
level));
-            break;
-          case 5:
-            points.add(interpolate(x, y, x, y + 1, grid, gridSize + 1, level));
-            points.add(interpolate(x, y + 1, x + 1, y + 1, grid, gridSize + 1, 
level));
-            points.add(interpolate(x + 1, y, x + 1, y + 1, grid, gridSize + 1, 
level));
-            points.add(interpolate(x + 1, y, x, y, grid, gridSize + 1, level));
-            break;
-          case 6:
-          case 9:
-            points.add(interpolate(x, y, x + 1, y, grid, gridSize + 1, level));
-            points.add(interpolate(x, y + 1, x + 1, y + 1, grid, gridSize + 1, 
level));
-            break;
-          case 7:
-          case 8:
-            points.add(interpolate(x, y, x, y + 1, grid, gridSize + 1, level));
-            points.add(interpolate(x, y + 1, x + 1, y + 1, grid, gridSize + 1, 
level));
-            break;
-          case 10:
-            points.add(interpolate(x, y, x + 1, y, grid, gridSize + 1, level));
-            points.add(interpolate(x + 1, y, x + 1, y + 1, grid, gridSize + 1, 
level));
-            points.add(interpolate(x + 1, y + 1, x, y + 1, grid, gridSize + 1, 
level));
-            points.add(interpolate(x, y + 1, x, y, grid, gridSize + 1, level));
-            break;
-        }
-        if (!points.isEmpty()) {
-          isoLines.add(new IsoLine(points));
-        }
-      }
-    }
-    return isoLines;
-  }
-
-  public static List<IsoLine> isoLines(double[] grid, int gridSize, int start, 
int end,
-      int interval) {
-    List<IsoLine> isoLines = new ArrayList<>();
-    for (int level = start; level < end; level++) {
-      isoLines.addAll(isoLines(grid, gridSize, level));
-    }
-    return isoLines;
-  }
-
-  private static Point interpolate(
-      int x1,
-      int y1,
-      int x2,
-      int y2,
-      double[] grid,
-      int width,
-      double level) {
-    double v1 = grid[y1 * width + x1];
-    double v2 = grid[y2 * width + x2];
-    double t = (level - v1) / (v2 - v1);
-    return new Point(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
-  }
-
-}
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/HillShade.java
 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/HillShade.java
new file mode 100644
index 00000000..5c92c264
--- /dev/null
+++ 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/HillShade.java
@@ -0,0 +1,150 @@
+/*
+ * 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.baremaps.raster.elevation;
+
+/**
+ * Provides methods for generating hillshade effects on digital elevation 
models (DEMs).
+ */
+public class HillShade {
+
+  private static final double DEFAULT_SCALE = 0.1;
+  private static final double ENHANCED_SCALE = 1.0;
+  private static final double MIN_REFLECTANCE = 0.0;
+  private static final double MAX_REFLECTANCE = 255.0;
+  private static final double TWO_PI = 2 * Math.PI;
+
+  private HillShade() {
+    // Prevent instantiation
+  }
+
+  /**
+   * Generates a hillshade effect on the given DEM.
+   *
+   * @param dem The digital elevation model data
+   * @param width The width of the DEM
+   * @param height The height of the DEM
+   * @param sunAltitude The sun's altitude in degrees
+   * @param sunAzimuth The sun's azimuth in degrees
+   * @return An array representing the hillshade effect
+   */
+  public static double[] hillShade(double[] dem, int width, int height, double 
sunAltitude,
+      double sunAzimuth) {
+    validateInput(dem, width, height, sunAltitude, sunAzimuth);
+    return calculateHillShade(dem, width, height, sunAltitude, sunAzimuth, 
DEFAULT_SCALE, true);
+  }
+
+  /**
+   * Generates a hillshade effect on the given DEM.
+   *
+   * @param dem The digital elevation model data
+   * @param width The width of the DEM
+   * @param height The height of the DEM
+   * @param sunAltitude The sun's altitude in degrees
+   * @param sunAzimuth The sun's azimuth in degrees
+   * @return An array representing the hillshade effect
+   */
+  public static double[] hillShade(double[] dem, int width, int height, double 
sunAltitude,
+      double sunAzimuth, double scale, boolean isSimple) {
+    validateInput(dem, width, height, sunAltitude, sunAzimuth);
+    return calculateHillShade(dem, width, height, sunAltitude, sunAzimuth, 
scale, isSimple);
+  }
+
+  /**
+   * Generates an enhanced hillshade effect on the given DEM.
+   *
+   * @param dem The digital elevation model data
+   * @param width The width of the DEM
+   * @param height The height of the DEM
+   * @param sunAltitude The sun's altitude in degrees
+   * @param sunAzimuth The sun's azimuth in degrees
+   * @return An array representing the enhanced hillshade effect
+   */
+  public static double[] hillShadeEnhanced(double[] dem, int width, int 
height, double sunAltitude,
+      double sunAzimuth) {
+    validateInput(dem, width, height, sunAltitude, sunAzimuth);
+    return calculateHillShade(dem, width, height, sunAltitude, sunAzimuth, 
ENHANCED_SCALE, false);
+  }
+
+  private static void validateInput(double[] dem, int width, int height, 
double sunAltitude,
+      double sunAzimuth) {
+    if (dem == null || dem.length == 0) {
+      throw new IllegalArgumentException("DEM array cannot be null or empty");
+    }
+    if (width <= 0 || height <= 0) {
+      throw new IllegalArgumentException("Width and height must be positive");
+    }
+    if (dem.length != width * height) {
+      throw new IllegalArgumentException("DEM array length does not match 
width * height");
+    }
+    if (sunAltitude < 0 || sunAltitude > 90) {
+      throw new IllegalArgumentException("Sun altitude must be between 0 and 
90 degrees");
+    }
+    if (sunAzimuth < 0 || sunAzimuth > 360) {
+      throw new IllegalArgumentException("Sun azimuth must be between 0 and 
360 degrees");
+    }
+  }
+
+  private static double[] calculateHillShade(double[] dem, int width, int 
height,
+      double sunAltitude, double sunAzimuth, double scale, boolean isSimple) {
+    double[] hillshade = new double[dem.length];
+
+    double sunAltitudeRad = Math.toRadians(sunAltitude);
+    double sunAzimuthRad = Math.toRadians(sunAzimuth + (isSimple ? 90 : 0));
+    double cosSunAltitude = Math.cos(sunAltitudeRad);
+    double sinSunAltitude = Math.sin(sunAltitudeRad);
+
+    for (int y = 0; y < height; y++) {
+      for (int x = 0; x < width; x++) {
+        int top = Math.max(y - 1, 0);
+        int bottom = Math.min(y + 1, height - 1);
+        int left = Math.max(x - 1, 0);
+        int right = Math.min(x + 1, width - 1);
+
+        double dzdx, dzdy;
+        if (isSimple) {
+          dzdx = (dem[y * width + right] - dem[y * width + left]) / 2.0;
+          dzdy = (dem[bottom * width + x] - dem[top * width + x]) / 2.0;
+        } else {
+          dzdx = ((dem[top * width + right] + 2 * dem[y * width + right]
+              + dem[bottom * width + right]) -
+              (dem[top * width + left] + 2 * dem[y * width + left] + 
dem[bottom * width + left]))
+              / 8.0;
+          dzdy = ((dem[bottom * width + left] + 2 * dem[bottom * width + x]
+              + dem[bottom * width + right]) -
+              (dem[top * width + left] + 2 * dem[top * width + x] + dem[top * 
width + right]))
+              / 8.0;
+        }
+
+        double slope = Math.atan(scale * Math.hypot(dzdx, dzdy));
+        double aspect = Math.atan2(dzdy, isSimple ? dzdx : -dzdx);
+        if (aspect < 0) {
+          aspect += TWO_PI;
+        }
+
+        double reflectance = cosSunAltitude * Math.cos(slope) +
+            sinSunAltitude * Math.sin(slope) * Math.cos(sunAzimuthRad - 
aspect);
+
+        hillshade[y * width + x] =
+            Math.max(MIN_REFLECTANCE, Math.min(MAX_REFLECTANCE, reflectance * 
MAX_REFLECTANCE));
+      }
+    }
+
+    return hillshade;
+  }
+
+}
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/IsoLines.java
 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/IsoLines.java
new file mode 100644
index 00000000..384c41cb
--- /dev/null
+++ 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/elevation/IsoLines.java
@@ -0,0 +1,211 @@
+/*
+ * 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.baremaps.raster.elevation;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.operation.linemerge.LineMerger;
+
+/**
+ * Provides methods for generating isoline contours from digital elevation 
models (DEMs).
+ */
+public class IsoLines {
+
+  private static final GeometryFactory GEOMETRY_FACTORY = new 
GeometryFactory();
+
+  private static final double EPSILON = 1e-10;
+
+  private IsoLines() {
+    // Prevent instantiation
+  }
+
+  /**
+   * Generates isolines for a given grid at a specific level.
+   *
+   * @param grid The elevation data
+   * @param width The width of the grid
+   * @param height The height of the grid
+   * @param level The elevation level for which to generate isolines
+   * @param normalize Whether to normalize the coordinates
+   * @return A list of LineString objects representing the isolines
+   */
+  public static List<LineString> generateIsoLines(
+      double[] grid, int width, int height,
+      double level, boolean normalize) {
+    validateInput(grid, width, height);
+    List<LineString> lineStrings = new ArrayList<>();
+    for (int y = 0; y < height - 1; y++) {
+      for (int x = 0; x < width - 1; x++) {
+        processCell(grid, width, height, level, normalize, lineStrings, y, x);
+      }
+    }
+    return mergeLineStrings(lineStrings);
+  }
+
+  /**
+   * Generates isolines for a given grid at multiple levels within a specified 
range.
+   *
+   * @param grid The elevation data
+   * @param width The width of the grid
+   * @param height The height of the grid
+   * @param start The starting elevation level
+   * @param end The ending elevation level
+   * @param interval The interval between elevation levels
+   * @param normalize Whether to normalize the coordinates
+   * @return A list of LineString objects representing the isolines
+   */
+  public static List<LineString> generateIsoLines(
+      double[] grid, int width, int height,
+      int start, int end, int interval,
+      boolean normalize) {
+    validateInput(grid, width, height);
+    List<LineString> isoLines = new ArrayList<>();
+    for (int level = start; level < end; level += interval) {
+      isoLines.addAll(generateIsoLines(grid, width, height, level, normalize));
+    }
+    return isoLines;
+  }
+
+  private static List<LineString> mergeLineStrings(List<LineString> 
lineStrings) {
+    LineMerger lineMerger = new LineMerger();
+    lineMerger.add(lineStrings);
+    return new ArrayList<>(lineMerger.getMergedLineStrings());
+  }
+
+  private static void validateInput(double[] grid, int width, int height) {
+    if (grid == null || grid.length == 0) {
+      throw new IllegalArgumentException("Grid array cannot be null or empty");
+    }
+    if (width <= 0 || height <= 0) {
+      throw new IllegalArgumentException("Width and height must be positive");
+    }
+    if (grid.length != width * height) {
+      throw new IllegalArgumentException("Grid array length does not match 
width * height");
+    }
+  }
+
+  private static void processCell(
+      double[] grid, int width, int height,
+      double level, boolean normalize, List<LineString> lineStrings,
+      int y, int x) {
+    double tl = grid[y * width + x];
+    double tr = grid[y * width + (x + 1)];
+    double br = grid[(y + 1) * width + (x + 1)];
+    double bl = grid[(y + 1) * width + x];
+
+    int index =
+        (tl > level ? 1 : 0) |
+            (tr > level ? 2 : 0) |
+            (br > level ? 4 : 0) |
+            (bl > level ? 8 : 0);
+
+    switch (index) {
+      case 1:
+      case 14:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x + 1, y,
+            x, y + 1, x, y);
+        break;
+      case 2:
+      case 13:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x + 1, y, x, y,
+            x + 1, y, x + 1, y + 1);
+        break;
+      case 3:
+      case 12:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x, y + 1,
+            x + 1, y, x + 1, y + 1);
+        break;
+      case 4:
+      case 11:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x + 1, y + 1, x + 1, y,
+            x + 1, y + 1, x, y + 1);
+        break;
+      case 5:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x, y + 1,
+            x, y + 1, x + 1, y + 1);
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x + 1, y, x + 1, y + 1,
+            x + 1, y, x, y);
+        break;
+      case 6:
+      case 9:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x + 1, y,
+            x, y + 1, x + 1, y + 1);
+        break;
+      case 7:
+      case 8:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x, y + 1,
+            x, y + 1, x + 1, y + 1);
+        break;
+      case 10:
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x, y, x + 1, y,
+            x + 1, y, x + 1, y + 1);
+        createLineString(
+            grid, width, height, level, normalize, lineStrings,
+            x + 1, y + 1, x, y + 1,
+            x, y + 1, x, y);
+        break;
+    }
+  }
+
+  private static void createLineString(
+      double[] grid, int width, int height,
+      double level, boolean normalize, List<LineString> lineStrings,
+      int x1, int y1, int x2, int y2,
+      int x3, int y3, int x4, int y4) {
+    Coordinate c1 = interpolate(grid, width, height, level, normalize, x1, y1, 
x2, y2);
+    Coordinate c2 = interpolate(grid, width, height, level, normalize, x3, y3, 
x4, y4);
+    lineStrings.add(GEOMETRY_FACTORY.createLineString(new Coordinate[] {c1, 
c2}));
+  }
+
+  private static Coordinate interpolate(
+      double[] grid, int width, int height,
+      double level, boolean normalize,
+      int x1, int y1, int x2, int y2) {
+    double v1 = grid[y1 * width + x1];
+    double v2 = grid[y2 * width + x2];
+    double t = (Math.abs(v2 - v1) < EPSILON) ? 0.5 : (level - v1) / (v2 - v1);
+    double x = x1 + t * (x2 - x1);
+    double y = y1 + t * (y2 - y1);
+    if (normalize) {
+      x = x / (width - 1) * width;
+      y = y / (height - 1) * height;
+    }
+    return new Coordinate(x, y);
+  }
+}
diff --git 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/hillshade/HillShade.java
 
b/baremaps-raster/src/main/java/org/apache/baremaps/raster/hillshade/HillShade.java
deleted file mode 100644
index 104e2376..00000000
--- 
a/baremaps-raster/src/main/java/org/apache/baremaps/raster/hillshade/HillShade.java
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * 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.baremaps.raster.hillshade;
-
-public class HillShade {
-
-  public static double[] hillShade(double[] dem, int width, int height, double 
sunAltitude,
-      double sunAzimuth) {
-    double[] hillshade = new double[dem.length];
-
-    double scale = 0.1; // Adjust the scale factor if needed
-
-    // Convert sun altitude and azimuth from degrees to radians
-    double sunAltitudeRad = Math.toRadians(sunAltitude);
-    double sunAzimuthRad = Math.toRadians(sunAzimuth + 90);
-
-    for (int y = 0; y < height; y++) {
-      for (int x = 0; x < width; x++) {
-
-        // Handle edge cases for border pixels
-        int top = Math.max(y - 1, 0);
-        int left = Math.max(x - 1, 0);
-        int bottom = Math.min(y + 1, height - 1);
-        int right = Math.min(x + 1, width - 1);
-
-        // Retrieve the elevation values from the 3x3 kernel
-        double z2 = dem[top * width + x];
-        double z4 = dem[y * width + left];
-        double z6 = dem[y * width + right];
-        double z8 = dem[bottom * width + x];
-
-        // Calculate the dz/dx and dz/dy using the 3x3 kernel
-        double dzdx = (z6 - z4) / 2.0;
-        double dzdy = (z8 - z2) / 2.0;
-
-        // Calculate the slope
-        double slope = Math.atan(scale * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
-
-        // Calculate the aspect
-        double aspect = Math.atan2(dzdy, dzdx);
-        if (aspect < 0) {
-          aspect += 2 * Math.PI;
-        }
-
-        // Calculate the reflectance
-        double reflectance = Math.cos(sunAltitudeRad)
-            * Math.cos(slope)
-            + Math.sin(sunAltitudeRad)
-                * Math.sin(slope)
-                * Math.cos(sunAzimuthRad - aspect);
-
-        // Normalize the reflectance to be between 0 and 255
-        hillshade[y * width + x] = Math.max(0, Math.min(255, reflectance * 
255));
-      }
-    }
-
-    return hillshade;
-  }
-
-  public static double[] hillShadeEnhanced(double[] dem, int width, int 
height, double sunAltitude,
-      double sunAzimuth) {
-    double[] hillshade = new double[dem.length];
-
-    double scale = 1.0; // Adjust the scale factor if needed
-
-    // Convert sun altitude and azimuth from degrees to radians
-    double sunAltitudeRad = Math.toRadians(sunAltitude);
-    double sunAzimuthRad = Math.toRadians(sunAzimuth);
-
-    for (int y = 0; y < height; y++) {
-      for (int x = 0; x < width; x++) {
-
-        // Handle edge cases for border pixels
-        int top = Math.max(y - 1, 0);
-        int bottom = Math.min(y + 1, height - 1);
-        int left = Math.max(x - 1, 0);
-        int right = Math.min(x + 1, width - 1);
-
-        // Retrieve the elevation values from the 3x3 kernel
-        double z1 = dem[top * width + left];
-        double z2 = dem[top * width + x];
-        double z3 = dem[top * width + right];
-        double z4 = dem[y * width + left];
-        double z6 = dem[y * width + right];
-        double z7 = dem[bottom * width + left];
-        double z8 = dem[bottom * width + x];
-        double z9 = dem[bottom * width + right];
-
-        // Calculate the dz/dx and dz/dy using the 3x3 kernel
-        double dzdx = ((z3 + 2 * z6 + z9) - (z1 + 2 * z4 + z7)) / 8.0;
-        double dzdy = ((z7 + 2 * z8 + z9) - (z1 + 2 * z2 + z3)) / 8.0;
-
-        // Calculate the slope
-        double slope = Math.atan(scale * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
-
-        // Calculate the aspect
-        double aspect = Math.atan2(dzdy, -dzdx);
-        if (aspect < 0) {
-          aspect += 2 * Math.PI;
-        }
-
-        // Calculate the reflectance
-        double reflectance = Math.cos(sunAltitudeRad)
-            * Math.cos(slope)
-            + Math.sin(sunAltitudeRad)
-                * Math.sin(slope)
-                * Math.cos(sunAzimuthRad - aspect);
-
-        // Normalize the reflectance to be between 0 and 255
-        hillshade[y * width + x] = Math.max(0, Math.min(255, reflectance * 
255));
-      }
-    }
-
-    return hillshade;
-  }
-}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLineRenderer.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLineRenderer.java
deleted file mode 100644
index 9f1539b6..00000000
--- 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLineRenderer.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * 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.baremaps.raster.contour;
-
-import static org.apache.baremaps.raster.contour.IsoLines.isoLines;
-
-import java.awt.*;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import javax.imageio.ImageIO;
-import javax.swing.*;
-import org.apache.baremaps.raster.contour.IsoLines.IsoLine;
-import org.apache.baremaps.raster.martini.Martini;
-
-public class IsoLineRenderer {
-
-  public static void main(String[] args) throws IOException {
-    var path = Path.of("")
-        .toAbsolutePath()
-        .resolveSibling("baremaps/baremaps-raster/src/test/resources/fuji.png")
-        .toAbsolutePath().toFile();
-
-    System.out.println(path);
-
-    var image = ImageIO.read(path);
-
-    double[] grid = Martini.grid(image);
-    List<IsoLine> contours = new ArrayList<>();
-    for (int i = 0; i < 8000; i += 100) {
-      contours.addAll(isoLines(grid, image.getWidth(), i));
-    }
-
-    // Create a frame to display the contours
-    JFrame frame = new JFrame("Contour Lines");
-    frame.setSize(image.getWidth(), image.getHeight());
-    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
-    frame.add(new ContourCanvas(contours));
-    frame.setVisible(true);
-  }
-
-  // Custom Canvas to draw the contours
-  static class ContourCanvas extends Canvas {
-    List<IsoLine> contours;
-
-    public ContourCanvas(List<IsoLine> contours) {
-      this.contours = contours;
-    }
-
-    @Override
-    public void paint(Graphics g) {
-      g.setColor(Color.RED);
-      for (IsoLine contour : contours) {
-        List<Point> points = contour.points()
-            .stream().map(p -> new Point((int) p.x(), (int) p.y()))
-            .toList();
-        for (int i = 0; i < points.size() - 1; i++) {
-          Point p1 = points.get(i);
-          Point p2 = points.get(i + 1);
-          g.drawLine(p1.x, p1.y, p2.x, p2.y);
-        }
-      }
-    }
-  }
-}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeRenderer.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeRenderer.java
new file mode 100644
index 00000000..ed0c57ca
--- /dev/null
+++ 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeRenderer.java
@@ -0,0 +1,160 @@
+/*
+ * 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.baremaps.raster.elevation;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Path;
+import javax.imageio.ImageIO;
+import javax.swing.*;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import org.apache.baremaps.raster.ElevationUtils;
+
+public class HillShadeRenderer extends JFrame {
+
+  private BufferedImage originalImage;
+  private double[] grid;
+  private JSlider altitudeSlider;
+  private JSlider azimuthSlider;
+  private JSlider scaleSlider;
+  private JCheckBox isSimpleCheckbox;
+  private JLabel imageLabel;
+  private JLabel altitudeLabel;
+  private JLabel azimuthLabel;
+  private JLabel scaleLabel;
+
+  public HillShadeRenderer() throws IOException {
+    super("Hillshade Display");
+    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+
+    // Load the image
+    originalImage = ImageIO.read(
+        Path.of("")
+            .toAbsolutePath()
+            
.resolveSibling("baremaps/baremaps-raster/src/test/resources/fuji.png")
+            .toAbsolutePath().toFile());
+    grid = ElevationUtils.imageToGrid(originalImage);
+
+    // Create UI components
+    altitudeSlider = new JSlider(JSlider.VERTICAL, 0, 90, 45);
+    azimuthSlider = new JSlider(JSlider.VERTICAL, 0, 360, 315);
+    scaleSlider = new JSlider(JSlider.HORIZONTAL, 1, 100, 10); // Scale from 
0.1 to 10.0
+    isSimpleCheckbox = new JCheckBox("Simple Algorithm", true);
+    imageLabel = new JLabel();
+    altitudeLabel = new JLabel("Sun Altitude: 45°");
+    azimuthLabel = new JLabel("Sun Azimuth: 315°");
+    scaleLabel = new JLabel("Scale: 1.0");
+
+    // Set up sliders
+    altitudeSlider.setMajorTickSpacing(15);
+    altitudeSlider.setPaintTicks(true);
+    altitudeSlider.setPaintLabels(true);
+
+    azimuthSlider.setMajorTickSpacing(45);
+    azimuthSlider.setPaintTicks(true);
+    azimuthSlider.setPaintLabels(true);
+
+    scaleSlider.setMajorTickSpacing(10);
+    scaleSlider.setPaintTicks(true);
+    scaleSlider.setPaintLabels(true);
+
+    // Add listeners
+    ChangeListener listener = new ChangeListener() {
+      public void stateChanged(ChangeEvent e) {
+        updateLabels();
+        redrawHillshade();
+      }
+    };
+    altitudeSlider.addChangeListener(listener);
+    azimuthSlider.addChangeListener(listener);
+    scaleSlider.addChangeListener(listener);
+    isSimpleCheckbox.addActionListener(e -> redrawHillshade());
+
+    // Set up layout
+    setLayout(new BorderLayout());
+    JPanel controlPanel = new JPanel(new GridBagLayout());
+    GridBagConstraints gbc = new GridBagConstraints();
+    gbc.gridx = 0;
+    gbc.gridy = 0;
+    gbc.insets = new Insets(5, 5, 5, 5);
+    controlPanel.add(altitudeLabel, gbc);
+    gbc.gridy++;
+    controlPanel.add(altitudeSlider, gbc);
+    gbc.gridy++;
+    controlPanel.add(azimuthLabel, gbc);
+    gbc.gridy++;
+    controlPanel.add(azimuthSlider, gbc);
+    gbc.gridy++;
+    controlPanel.add(scaleLabel, gbc);
+    gbc.gridy++;
+    controlPanel.add(scaleSlider, gbc);
+    gbc.gridy++;
+    controlPanel.add(isSimpleCheckbox, gbc);
+
+    add(imageLabel, BorderLayout.CENTER);
+    add(controlPanel, BorderLayout.EAST);
+
+    // Initial draw
+    redrawHillshade();
+
+    pack();
+    setVisible(true);
+  }
+
+  private void updateLabels() {
+    altitudeLabel.setText("Sun Altitude: " + altitudeSlider.getValue() + "°");
+    azimuthLabel.setText("Sun Azimuth: " + azimuthSlider.getValue() + "°");
+    scaleLabel.setText("Scale: " + (scaleSlider.getValue() / 10.0));
+  }
+
+  private void redrawHillshade() {
+    int sunAltitude = altitudeSlider.getValue();
+    int sunAzimuth = azimuthSlider.getValue();
+    double scale = scaleSlider.getValue() / 10.0;
+    boolean isSimple = isSimpleCheckbox.isSelected();
+
+    double[] hillshade = HillShade.hillShade(grid, originalImage.getWidth(),
+        originalImage.getHeight(), sunAltitude, sunAzimuth, scale, isSimple);
+
+    BufferedImage hillshadeImage = new BufferedImage(originalImage.getWidth(),
+        originalImage.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
+    for (int y = 0; y < originalImage.getHeight(); y++) {
+      for (int x = 0; x < originalImage.getWidth(); x++) {
+        int shade = (int) hillshade[y * originalImage.getWidth() + x];
+        int rgb = new Color(shade, shade, shade).getRGB();
+        hillshadeImage.setRGB(x, y, rgb);
+      }
+    }
+
+    imageLabel.setIcon(new ImageIcon(hillshadeImage));
+    revalidate();
+    repaint();
+  }
+
+  public static void main(String[] args) {
+    SwingUtilities.invokeLater(() -> {
+      try {
+        new HillShadeRenderer();
+      } catch (IOException e) {
+        e.printStackTrace();
+      }
+    });
+  }
+}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeTest.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeTest.java
new file mode 100644
index 00000000..da572ae0
--- /dev/null
+++ 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/HillShadeTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.baremaps.raster.elevation;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import javax.imageio.ImageIO;
+import org.apache.baremaps.raster.ElevationUtils;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.locationtech.jts.geom.LineString;
+
+class HillShadeTest {
+
+  private static final double DELTA = 1e-6;
+
+  @Test
+  @DisplayName("Test hillShade with valid input")
+  void testHillShadeValidInput() {
+    double[] dem = {
+        1, 2, 3,
+        4, 5, 6,
+        7, 8, 9
+    };
+    int width = 3;
+    int height = 3;
+    double sunAltitude = 45;
+    double sunAzimuth = 315;
+
+    double[] result = HillShade.hillShade(dem, width, height, sunAltitude, 
sunAzimuth);
+
+    assertNotNull(result);
+    assertEquals(dem.length, result.length);
+  }
+
+  @Test
+  @DisplayName("Test hillShadeEnhanced with valid input")
+  void testHillShadeEnhancedValidInput() {
+    double[] dem = {
+        1, 2, 3,
+        4, 5, 6,
+        7, 8, 9
+    };
+    int width = 3;
+    int height = 3;
+    double sunAltitude = 45;
+    double sunAzimuth = 315;
+
+    double[] result = HillShade.hillShadeEnhanced(dem, width, height, 
sunAltitude, sunAzimuth);
+
+    assertNotNull(result);
+    assertEquals(dem.length, result.length);
+  }
+
+  @ParameterizedTest
+  @MethodSource("provideInvalidInput")
+  @DisplayName("Test hillShade with invalid input")
+  void testHillShadeInvalidInput(double[] dem, int width, int height, double 
sunAltitude,
+      double sunAzimuth, Class<? extends Exception> expectedException) {
+    assertThrows(expectedException,
+        () -> HillShade.hillShade(dem, width, height, sunAltitude, 
sunAzimuth));
+  }
+
+  @ParameterizedTest
+  @MethodSource("provideInvalidInput")
+  @DisplayName("Test hillShadeEnhanced with invalid input")
+  void testHillShadeEnhancedInvalidInput(double[] dem, int width, int height, 
double sunAltitude,
+      double sunAzimuth, Class<? extends Exception> expectedException) {
+    assertThrows(expectedException,
+        () -> HillShade.hillShadeEnhanced(dem, width, height, sunAltitude, 
sunAzimuth));
+  }
+
+  private static Stream<Arguments> provideInvalidInput() {
+    return Stream.of(
+        Arguments.of(null, 3, 3, 45, 315, IllegalArgumentException.class),
+        Arguments.of(new double[0], 3, 3, 45, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 0, 3, 45, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 3, 0, 45, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 2, 2, 45, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 3, 3, -1, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 3, 3, 91, 315, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 3, 3, 45, -1, 
IllegalArgumentException.class),
+        Arguments.of(new double[9], 3, 3, 45, 361, 
IllegalArgumentException.class));
+  }
+
+  @Test
+  @DisplayName("Test hillShade output range")
+  void testHillShadeOutputRange() {
+    double[] dem = new double[100];
+    for (int i = 0; i < dem.length; i++) {
+      dem[i] = Math.random() * 1000;
+    }
+    int width = 10;
+    int height = 10;
+    double sunAltitude = 45;
+    double sunAzimuth = 315;
+
+    double[] result = HillShade.hillShade(dem, width, height, sunAltitude, 
sunAzimuth);
+
+    for (double value : result) {
+      assertTrue(value >= 0 && value <= 255, "Hillshade value should be 
between 0 and 255");
+    }
+  }
+
+  @Test
+  @DisplayName("Test hillShadeEnhanced output range")
+  void testHillShadeEnhancedOutputRange() {
+    double[] dem = new double[100];
+    for (int i = 0; i < dem.length; i++) {
+      dem[i] = Math.random() * 1000;
+    }
+    int width = 10;
+    int height = 10;
+    double sunAltitude = 45;
+    double sunAzimuth = 315;
+
+    double[] result = HillShade.hillShadeEnhanced(dem, width, height, 
sunAltitude, sunAzimuth);
+
+    for (double value : result) {
+      assertTrue(value >= 0 && value <= 255, "Hillshade value should be 
between 0 and 255");
+    }
+  }
+
+  @Test
+  @DisplayName("Test hillShade with flat terrain")
+  void testHillShadeWithFlatTerrain() {
+    double[] dem = new double[9];
+    Arrays.fill(dem, 100);
+    int width = 3;
+    int height = 3;
+    double sunAltitude = 90;
+    double sunAzimuth = 0;
+
+    double[] result = HillShade.hillShade(dem, width, height, sunAltitude, 
sunAzimuth);
+
+    for (double value : result) {
+      assertEquals(255, value, DELTA,
+          "Flat terrain with sun overhead should result in maximum 
brightness");
+    }
+  }
+
+  @Test
+  @DisplayName("Test hillShadeEnhanced with flat terrain")
+  void testHillShadeEnhancedWithFlatTerrain() {
+    double[] dem = new double[9];
+    Arrays.fill(dem, 100);
+    int width = 3;
+    int height = 3;
+    double sunAltitude = 90;
+    double sunAzimuth = 0;
+
+    double[] result = HillShade.hillShadeEnhanced(dem, width, height, 
sunAltitude, sunAzimuth);
+
+    for (double value : result) {
+      assertEquals(255, value, DELTA,
+          "Flat terrain with sun overhead should result in maximum 
brightness");
+    }
+  }
+
+  @Test
+  @DisplayName("Test hillShade with fuji.png")
+  void testHillShadeWithFujiPng(@TempDir Path tempDir) throws IOException {
+    Path imagePath = Path.of("")
+        .toAbsolutePath()
+        .resolveSibling("baremaps-raster/src/test/resources/fuji.png")
+        .toAbsolutePath();
+    var png = ImageIO.read(imagePath.toFile());
+    assertNotNull(png, "Failed to load test image");
+
+    var grid = ElevationUtils.imageToGrid(png);
+    int width = png.getWidth();
+    int height = png.getHeight();
+    double sunAltitude = 45;
+    double sunAzimuth = 315;
+
+    var hillshade = HillShade.hillShade(grid, width, height, sunAltitude, 
sunAzimuth);
+
+    assertNotNull(hillshade, "Hillshade result should not be null");
+    assertEquals(width * height, hillshade.length,
+        "Hillshade array size should match image dimensions");
+
+    var isoLines = new ArrayList<LineString>();
+    for (int i = 0; i < 255; i += 50) {
+      List<LineString> lines = IsoLines.generateIsoLines(hillshade, width, 
height, i, true);
+      assertNotNull(lines, "Isoline generation should not return null");
+      isoLines.addAll(lines);
+    }
+
+    assertFalse(isoLines.isEmpty(), "At least one isoline should be 
generated");
+
+    BufferedImage hillshadeImage = new BufferedImage(width, height, 
BufferedImage.TYPE_BYTE_GRAY);
+    for (int y = 0; y < height; y++) {
+      for (int x = 0; x < width; x++) {
+        int gray = (int) hillshade[y * width + x];
+        hillshadeImage.setRGB(x, y, (gray << 16) | (gray << 8) | gray);
+      }
+    }
+    Path outputPath = tempDir.resolve("fuji_hillshade.png");
+    ImageIO.write(hillshadeImage, "png", outputPath.toFile());
+    assertTrue(outputPath.toFile().exists(), "Hillshade image should be 
saved");
+  }
+}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesRenderer.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesRenderer.java
new file mode 100644
index 00000000..9643934d
--- /dev/null
+++ 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesRenderer.java
@@ -0,0 +1,132 @@
+/*
+ * 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.baremaps.raster.elevation;
+
+import static org.apache.baremaps.raster.elevation.IsoLines.generateIsoLines;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import javax.imageio.ImageIO;
+import javax.swing.*;
+import org.apache.baremaps.raster.ElevationUtils;
+import org.locationtech.jts.geom.LineString;
+
+public class IsoLinesRenderer {
+
+  public static BufferedImage resizeImage(BufferedImage originalImage, int 
targetWidth,
+      int targetHeight) throws IOException {
+    Image resultingImage =
+        originalImage.getScaledInstance(targetWidth, targetHeight, 
Image.SCALE_DEFAULT);
+    BufferedImage outputImage =
+        new BufferedImage(targetWidth, targetHeight, 
BufferedImage.TYPE_INT_RGB);
+    outputImage.getGraphics().drawImage(resultingImage, 0, 0, null);
+    return outputImage;
+  }
+
+  public static void main(String[] args) throws IOException {
+    var path = Path.of("")
+        .toAbsolutePath()
+        .resolveSibling("baremaps/baremaps-raster/src/test/resources/fuji.png")
+        .toAbsolutePath().toFile();
+
+    var image1 = ImageIO.read(path);
+    double[] grid1 = ElevationUtils.imageToGrid(image1);
+    List<LineString> contours1 = new ArrayList<>();
+    for (int i = 0; i < 8000; i += 100) {
+      contours1.addAll(generateIsoLines(grid1, image1.getWidth(), 
image1.getHeight(), i, true));
+    }
+
+    // Downscale the image by 16
+    var image2 = resizeImage(image1, 32, 32);
+    double[] grid2 = ElevationUtils.imageToGrid(image2);
+    List<LineString> contours2 = new ArrayList<>();
+    for (int i = 0; i < 8000; i += 100) {
+      for (LineString lineString : generateIsoLines(grid2, image2.getWidth(), 
image2.getHeight(), i,
+          true)) {
+        // Upscale the line string by 16
+        lineString = (LineString) lineString.clone();
+        for (int j = 0; j < lineString.getNumPoints(); j++) {
+          lineString.getCoordinates()[j].x *= 16;
+          lineString.getCoordinates()[j].y *= 16;
+        }
+        contours2.add(lineString);
+
+      }
+    }
+
+    // Create a frame to display the contours
+    JFrame frame = new JFrame("Contour Lines");
+    frame.setSize(image1.getWidth(), image1.getHeight());
+    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+    frame.add(new ContourCanvas(image1, contours1, contours2));
+    frame.setVisible(true);
+  }
+
+  // Custom Canvas to draw the contours
+  static class ContourCanvas extends Canvas {
+
+    Image image;
+
+    List<LineString> contours1;
+
+    List<LineString> contours2;
+
+    public ContourCanvas(Image image, List<LineString> contours1, 
List<LineString> contours2) {
+      this.image = image;
+      this.contours1 = contours1;
+      this.contours2 = contours2;
+    }
+
+    @Override
+    public void paint(Graphics g) {
+
+      // Draw the image
+      g.drawImage(image, 0, 0, null);
+
+      g.setColor(Color.BLACK);
+      for (LineString contour : contours1) {
+        List<Point> points = Stream.of(contour.getCoordinates())
+            .map(p -> new Point((int) p.getX(), (int) p.getY()))
+            .toList();
+        for (int i = 0; i < points.size() - 1; i++) {
+          Point p1 = points.get(i);
+          Point p2 = points.get(i + 1);
+          g.drawLine(p1.x, p1.y, p2.x, p2.y);
+        }
+      }
+
+      g.setColor(Color.BLUE);
+      for (LineString contour : contours2) {
+        List<Point> points = Stream.of(contour.getCoordinates())
+            .map(p -> new Point((int) p.getX(), (int) p.getY()))
+            .toList();
+        for (int i = 0; i < points.size() - 1; i++) {
+          Point p1 = points.get(i);
+          Point p2 = points.get(i + 1);
+          g.drawLine(p1.x, p1.y, p2.x, p2.y);
+        }
+      }
+
+    }
+  }
+}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLinesTest.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesTest.java
similarity index 71%
rename from 
baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLinesTest.java
rename to 
baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesTest.java
index 5955d1f0..a3bdc191 100644
--- 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/contour/IsoLinesTest.java
+++ 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/elevation/IsoLinesTest.java
@@ -15,16 +15,28 @@
  * limitations under the License.
  */
 
-package org.apache.baremaps.raster.contour;
+package org.apache.baremaps.raster.elevation;
 
 import java.io.IOException;
 import java.nio.file.Path;
 import javax.imageio.ImageIO;
-import org.apache.baremaps.raster.martini.Martini;
+import org.apache.baremaps.raster.ElevationUtils;
 import org.junit.jupiter.api.Test;
 
 class IsoLinesTest {
 
+  @Test
+  void grid1() throws IOException {
+    var grid = new double[] {
+        0, 0, 0,
+        0, 1, 0,
+        0, 0, 0,
+    };
+    var contour = IsoLines.generateIsoLines(grid, 3, 3, 0, true);
+    System.out.println(contour);
+  }
+
+
   @Test
   void contour() throws IOException {
     var png = ImageIO.read(
@@ -32,8 +44,9 @@ class IsoLinesTest {
             .toAbsolutePath()
             .resolveSibling("baremaps-raster/src/test/resources/fuji.png")
             .toAbsolutePath().toFile());
-    var terrainGrid = Martini.grid(png);
-    var contour = IsoLines.isoLines(terrainGrid, png.getWidth(), 500);
+    var terrainGrid = ElevationUtils.imageToGrid(png);
+    var contour =
+        IsoLines.generateIsoLines(terrainGrid, png.getWidth(), 
png.getHeight(), 500, true);
     System.out.println(contour);
   }
 }
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeRenderer.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeRenderer.java
deleted file mode 100644
index 3112c6ec..00000000
--- 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeRenderer.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * 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.baremaps.raster.hillshade;
-
-import java.awt.*;
-import java.awt.image.BufferedImage;
-import java.io.IOException;
-import java.nio.file.Path;
-import javax.imageio.ImageIO;
-import javax.swing.*;
-import org.apache.baremaps.raster.ImageUtils;
-
-public class HillShadeRenderer {
-
-  public static void main(String[] args) throws IOException {
-    var image = ImageIO.read(
-        Path.of("")
-            .toAbsolutePath()
-            
.resolveSibling("baremaps/baremaps-raster/src/test/resources/fuji.png")
-            .toAbsolutePath().toFile());
-    var grid = ImageUtils.grid(image);
-    var hillshade = HillShade.hillShade(grid, image.getWidth(), 
image.getHeight(), 45, 315);
-
-
-    // Create an output image
-    BufferedImage hillshadeImage =
-        new BufferedImage(image.getWidth(), image.getHeight(), 
BufferedImage.TYPE_BYTE_GRAY);
-    for (int y = 0; y < image.getHeight(); y++) {
-      for (int x = 0; x < image.getWidth(); x++) {
-        int shade = (int) hillshade[y * image.getWidth() + x];
-        int rgb = new Color(shade, shade, shade).getRGB();
-        hillshadeImage.setRGB(x, y, rgb);
-      }
-    }
-
-    // Display the hillshade image in a JFrame
-    JFrame frame = new JFrame("Hillshade Display");
-    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
-    frame.setSize(image.getWidth(), image.getHeight());
-    frame.add(new JLabel(new ImageIcon(hillshadeImage)));
-    frame.pack();
-    frame.setVisible(true);
-  }
-}
diff --git 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeTest.java
 
b/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeTest.java
deleted file mode 100644
index ec9c3265..00000000
--- 
a/baremaps-raster/src/test/java/org/apache/baremaps/raster/hillshade/HillShadeTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * 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.baremaps.raster.hillshade;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import javax.imageio.ImageIO;
-import org.apache.baremaps.raster.contour.IsoLines;
-import org.apache.baremaps.raster.contour.IsoLines.IsoLine;
-import org.apache.baremaps.raster.martini.Martini;
-import org.junit.jupiter.api.Test;
-
-class HillShadeTest {
-
-  @Test
-  void hillshade() throws IOException {
-    var png = ImageIO.read(
-        Path.of("")
-            .toAbsolutePath()
-            .resolveSibling("baremaps-raster/src/test/resources/fuji.png")
-            .toAbsolutePath().toFile());
-    var grid = Martini.grid(png);
-    var hillshade = HillShade.hillShade(grid, png.getWidth(), png.getHeight(), 
45, 315);
-    var isoLines = new ArrayList<IsoLine>();
-    for (int i = 0; i < 255; i += 50) {
-      isoLines.addAll(IsoLines.isoLines(hillshade, png.getWidth(), i));
-    }
-
-    System.out.println(isoLines);
-  }
-
-}
diff --git a/baremaps-server/src/main/resources/raster/favicon.ico 
b/baremaps-server/src/main/resources/raster/favicon.ico
new file mode 100644
index 00000000..7162b07e
Binary files /dev/null and 
b/baremaps-server/src/main/resources/raster/favicon.ico differ
diff --git a/baremaps-server/src/main/resources/raster/hillshade.html 
b/baremaps-server/src/main/resources/raster/hillshade.html
new file mode 100644
index 00000000..23dd1819
--- /dev/null
+++ b/baremaps-server/src/main/resources/raster/hillshade.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>3D Terrain</title>
+    <meta property="og:description" content="Go beyond hillshade and show 
elevation in actual 3D." />
+    <meta charset='utf-8'>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link rel='stylesheet' 
href='https://unpkg.com/[email protected]/dist/maplibre-gl.css' />
+    <script 
src='https://unpkg.com/[email protected]/dist/maplibre-gl.js'></script>
+    <style>
+        body { margin: 0; padding: 0; }
+        html, body, #map { height: 100%; }
+    </style>
+</head>
+<body>
+<div id="map"></div>
+<script>
+    const map = (window.map = new maplibregl.Map({
+        container: 'map',
+        zoom: 11,
+        center: [11.5519, 47.2719],
+        hash: true,
+        style: {
+            version: 8,
+            sources: {
+                rasterSource: {
+                    type: 'raster',
+                    'tiles': [
+                        'http://localhost:9000/tiles/{z}/{x}/{y}.png'
+                    ],
+                    tileSize: 256
+                },
+            },
+            layers: [
+                {
+                    'id': 'raster',
+                    'type': 'raster',
+                    'source': 'rasterSource',
+                    'paint': {}
+                },
+            ],
+        },
+        maxZoom: 18,
+        maxPitch: 85
+    }));
+
+    map.addControl(
+        new maplibregl.NavigationControl({
+            visualizePitch: false,
+            showZoom: true,
+            showCompass: true
+        })
+    );
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 155b42c3..ed38acf7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -116,6 +116,7 @@ limitations under the License.
     <version.lib.slf4j>2.0.12</version.lib.slf4j>
     <version.lib.sqlite>3.45.1.0</version.lib.sqlite>
     <version.lib.testcontainers>1.19.6</version.lib.testcontainers>
+    <version.lib.twelvemonkeys>3.11.0</version.lib.twelvemonkeys>
     
<version.plugin.jacoco-maven-plugin>0.8.11</version.plugin.jacoco-maven-plugin>
     <version.plugin.jib-maven-plugin>3.0.0</version.plugin.jib-maven-plugin>
     
<version.plugin.maven-compiler-plugin>3.10.1</version.plugin.maven-compiler-plugin>
@@ -137,7 +138,6 @@ limitations under the License.
 
   <dependencyManagement>
     <dependencies>
-
       <dependency>
         <groupId>com.fasterxml.jackson.core</groupId>
         <artifactId>jackson-annotations</artifactId>
@@ -197,7 +197,7 @@ limitations under the License.
       <dependency>
         <groupId>com.twelvemonkeys.imageio</groupId>
         <artifactId>imageio-tiff</artifactId>
-        <version>3.11.0</version>
+        <version>${version.lib.twelvemonkeys}</version>
       </dependency>
       <dependency>
         <groupId>com.zaxxer</groupId>

Reply via email to