diff --git a/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java b/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java
index 2e637a12d1..41fb48b2a9 100644
--- a/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java
+++ b/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java
@@ -2,6 +2,7 @@
 package org.openstreetmap.josm.data.cache;
 
 import java.awt.image.BufferedImage;
+import java.awt.image.RenderedImage;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -37,7 +38,7 @@ public class BufferedImageCacheEntry extends CacheEntry {
      * @return a cache entry for the PNG encoded image
      * @throws UncheckedIOException if an I/O error occurs
      */
-    public static BufferedImageCacheEntry pngEncoded(BufferedImage img) {
+    public static BufferedImageCacheEntry pngEncoded(RenderedImage img) {
         try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
             ImageIO.write(img, "png", output);
             return new BufferedImageCacheEntry(output.toByteArray());
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
index 418fcfd520..73d7e42daa 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
@@ -21,6 +21,7 @@ import java.awt.image.BufferedImage;
 import java.io.IOException;
 import java.util.Objects;
 import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import javax.swing.JComponent;
 import javax.swing.SwingUtilities;
@@ -34,6 +35,7 @@ import org.openstreetmap.josm.gui.MainApplication;
 import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
 import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
 import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
 import org.openstreetmap.josm.gui.util.GuiHelper;
@@ -87,6 +89,8 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
 
     private final ImgDisplayMouseListener imgMouseListener = new ImgDisplayMouseListener();
 
+    private final AtomicInteger zoom = new AtomicInteger(12);
+
     private String emptyText;
     private String osdText;
 
@@ -364,10 +368,14 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             if (rotation > 0) {
                 currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
                 currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
+                ImageDisplay.this.zoom.decrementAndGet();
             } else {
                 currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
                 currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
+                ImageDisplay.this.zoom.incrementAndGet();
             }
+            // FIXME Remove logging
+            Logging.error("Current zoom: {0}", ImageDisplay.this.zoom.get());
 
             // Check that the zoom doesn't exceed MAX_ZOOM:1
             ensureMaxZoom(currentVisibleRect);
@@ -719,7 +727,11 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             Rectangle r = new Rectangle(currentVisibleRect);
             Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
 
-            currentImageViewer.paintImage(g, currentImage, target, r);
+            if (currentEntry instanceof IImageTiling && ((IImageTiling) currentEntry).isTilingEnabled()) {
+                currentImageViewer.paintTiledImage(g, (IImageTiling) currentEntry, target, r, zoom.get());
+            } else {
+                currentImageViewer.paintImage(g, currentImage, target, r);
+            }
             paintSelectedRect(g, target, currentVisibleRect, size);
             if (currentErrorLoading && currentEntry != null) {
                 String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
@@ -1017,6 +1029,19 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         } else {
             rectangle.height = wFact / getSize().width;
         }
+
+        final IImageEntry<?> currentEntry;
+        synchronized (this) {
+            currentEntry = this.entry;
+        }
+        if (currentEntry instanceof IImageTiling) {
+            IImageTiling imageTiling = (IImageTiling) currentEntry;
+            if (this.zoom.get() > imageTiling.getMaxZoom()) {
+                this.zoom.set(imageTiling.getMaxZoom());
+            } else if (this.zoom.get() < imageTiling.getMinZoom()) {
+                this.zoom.set(imageTiling.getMinZoom());
+            }
+        }
     }
 
     /**
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
index d5a94886b1..888611fbd2 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
@@ -6,6 +6,7 @@ import static org.openstreetmap.josm.tools.I18n.tr;
 import java.awt.Dimension;
 import java.awt.Graphics2D;
 import java.awt.Image;
+import java.awt.Rectangle;
 import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
 import java.io.File;
@@ -15,6 +16,7 @@ import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.Collections;
 import java.util.Objects;
+
 import javax.imageio.IIOParam;
 import javax.imageio.ImageReadParam;
 import javax.imageio.ImageReader;
@@ -22,6 +24,7 @@ import javax.imageio.ImageReader;
 import org.openstreetmap.josm.data.ImageData;
 import org.openstreetmap.josm.data.gpx.GpxImageEntry;
 import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
 import org.openstreetmap.josm.tools.ExifReader;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.Logging;
@@ -30,7 +33,7 @@ import org.openstreetmap.josm.tools.Logging;
  * Stores info about each image, with an optional thumbnail
  * @since 2662
  */
-public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry> {
+public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>, IImageTiling {
 
     private Image thumbnail;
     private ImageData dataSet;
@@ -212,10 +215,44 @@ public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>
         return applyExifRotation(image);
     }
 
+    @Override
+    public Image getTileImage(int zoom, int tileSize, int column, int row) {
+        if (column < 0 || row < 0 || zoom > this.getMaxZoom() || this.getWidth(zoom) < column * tileSize || this.getHeight(zoom) < row * tileSize) {
+            return null;
+        }
+        final URL imageUrl;
+        final BufferedImage image;
+        try {
+            imageUrl = getImageUrl();
+            Logging.info(tr("Loading {0} at {1}/{2}/{3} with size {4}", imageUrl, zoom, column, row, tileSize));
+            image = ImageProvider.read(imageUrl, true, false,
+                    r -> this.withSubsampling(r, tileSize, zoom, column, row));
+        } catch (IOException e) {
+            Logging.error(e);
+            return null;
+        }
+
+        if (image == null) {
+            Logging.warn("Unable to load {0}", imageUrl);
+        }
+        // applyExifRotation not used here since it will not work with tiled images
+        // Instead, we will have to rotate the column/row, and then apply rotation here.
+        return image;
+    }
+
     protected URL getImageUrl() throws MalformedURLException {
         return getFile().toURI().toURL();
     }
 
+    private ImageReadParam withSubsampling(ImageReader reader, int tileSize, int zoom, int column, int row) {
+        Rectangle tile = IImageTiling.super.getTileDimension(zoom, column, row, tileSize);
+        ImageReadParam param = reader.getDefaultReadParam();
+        param.setSourceRegion(tile);
+        int subsampling = (int) Math.floor(Math.max(Math.pow(IImageTiling.super.getScale(zoom), -1), 1));
+        param.setSourceSubsampling(subsampling, subsampling, 0, 0);
+        return param;
+    }
+
     private ImageReadParam withSubsampling(ImageReader reader, Dimension target) {
         try {
             ImageReadParam param = reader.getDefaultReadParam();
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
index 3c1d41e534..583bd53f97 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
@@ -12,6 +12,7 @@ import java.util.Set;
 
 import org.openstreetmap.josm.data.imagery.street_level.Projections;
 import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
 import org.openstreetmap.josm.gui.util.imagery.Vector3D;
 
 /**
@@ -34,6 +35,31 @@ public interface IImageViewer extends ComponentListener {
      */
     void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect);
 
+
+    /**
+     * Paint the image tile
+     * @param g The graphics to paint on
+     * @param entry The image entry (specifically, with the tile size)
+     * @param tile The tile to paint (x, y, z)
+     * @param image The image to paint
+     */
+    default void paintImageTile(Graphics g, Rectangle target, Rectangle visibleRect, IImageTiling entry, IImageTiling.ImageTile tile, Image image) {
+        final Rectangle toUse = visibleRect;
+        g.drawImage(image, -toUse.x + entry.getTileSize() * tile.getXIndex(), -toUse.y + entry.getTileSize() * tile.getYIndex(), null);
+    }
+
+    /**
+     * Paint the image
+     * @param g The graphics to paint on
+     * @param imageEntry The image to paint
+     * @param target The target area
+     * @param visibleRect The visible rectangle
+     * @param zoom The zoom level
+     */
+    default void paintTiledImage(Graphics g, IImageTiling imageEntry, Rectangle target, Rectangle visibleRect, int zoom) {
+        imageEntry.getTiles(zoom, visibleRect).forEach(pair -> this.paintImageTile(g, target, visibleRect, imageEntry, pair.a, pair.b));
+    }
+
     /**
      * Get the default visible rectangle for the projection
      * @param component The component the image will be displayed in
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
new file mode 100644
index 0000000000..7413517fbc
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
@@ -0,0 +1,271 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.awt.Image;
+import java.awt.Rectangle;
+import java.awt.image.RenderedImage;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.function.IntUnaryOperator;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.apache.commons.jcs3.access.CacheAccess;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.cache.JCSCacheManager;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.Pair;
+
+/**
+ * An interface for tiled images. Primarily used to reduce memory usage in large images.
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface IImageTiling {
+    /**
+     * Just a class to hold tile information
+     */
+    class ImageTile extends TileXY {
+        final int zoom;
+        /**
+         * Returns an instance of an image tile.
+         * @param x number of the tile
+         * @param y number of the tile
+         * @param z The zoom level
+         */
+        public ImageTile(int x, int y, int z) {
+            super(x, y);
+            this.zoom = z;
+        }
+    }
+    /**
+     * The default tile size for the image tiles -- each tile takes 1024 px * 1024 px * 4 bytes = 4 MiB max
+     * A 4k image (4160x3120) has (Math.ceil(4160/1024) * Math.ceil(3120/1024) = 20 tiles). Some tiles are almost empty.
+     * This gives a reasonable number of tiles for most image sizes.
+     */
+    int DEFAULT_TILE_SIZE = 1024;
+
+    /** A good default minimum zoom (the image size is {@link #DEFAULT_TILE_SIZE} max, at 1024 it is 5) */
+    int DEFAULT_MIN_ZOOM = (int) Math.round(Math.log(Math.sqrt(DEFAULT_TILE_SIZE))/Math.log(2));
+
+    /** A cache for images */
+    CacheAccess<String, BufferedImageCacheEntry> IMAGE_CACHE = JCSCacheManager.getCache("iimagetiling", 100, 1_000, Config.getDirs().getCacheDirectory(true).getAbsolutePath());
+
+    /**
+     * Get the size of the image at a specified zoom level
+     * @param zoom The zoom level. Zoom 0 == 1 px for the image. Zoom 1 == 4 px for the image.
+     * @return The number of pixels (max, for a square image)
+     */
+    static long getSizeAtZoom(final int zoom) {
+        final long dimension = 1L << zoom;
+        return dimension * dimension;
+    }
+
+    /**
+     * Get the default tile size.
+     * @return The tile size to use
+     */
+    default int getDefaultTileSize() {
+        return DEFAULT_TILE_SIZE;
+    }
+
+    /**
+     * Get the tile size.
+     * @return The tile size to use
+     */
+    default int getTileSize() {
+        return this.getDefaultTileSize();
+    }
+
+    /**
+     * Get the maximum zoom that the image supports
+     * Feel free to override and cache the result for performance reasons.
+     *
+     * @return The maximum zoom of the image
+     */
+    default int getMaxZoom() {
+        final int maxSize = Math.max(this.getWidth(), this.getHeight());
+        return (int) Math.round(Math.ceil(Math.log(maxSize) / Math.log(2)));
+    }
+
+    /**
+     * Get the minimum zoom that the image supports or makes sense
+     * @return The minimum zoom that makes sense
+     */
+    default int getMinZoom() {
+        final IntUnaryOperator minZoom = input -> Math.toIntExact(Math.round(Math.floor(this.getMaxZoom() + Math.log((double) this.getTileSize() / input) / Math.log(2))));
+        return Math.min(minZoom.applyAsInt(this.getWidth()), minZoom.applyAsInt(this.getHeight()));
+    }
+
+    /**
+     * Get the current scale of the image
+     * @param zoom The zoom level
+     * @return The scaling of the image at the specified level
+     */
+    default double getScale(final int zoom) {
+        return Math.pow(2, (double) zoom - this.getMaxZoom());
+    }
+
+    /**
+     * Get the width of the image
+     * @return The width of the image
+     */
+    int getWidth();
+
+    /**
+     * Get the width of the image at a specified scale
+     * @param zoom The zoom to use
+     * @return The width at the specified scale
+     */
+    default int getWidth(final int zoom) {
+        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getWidth()));
+    }
+
+    /**
+     * Get the height of the image
+     * @return The height of the image
+     */
+    int getHeight();
+
+    /**
+     * Get the height of the image at a specified scale
+     * @param zoom The zoom to use
+     * @return The height at the specified scale
+     */
+    default int getHeight(final int zoom) {
+        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getHeight()));
+    }
+
+    /**
+     * Get the number of rows at a specified zoom level
+     * @param zoom The zoom level
+     * @return The number of rows
+     */
+    default int getRows(final int zoom) {
+        return this.getRows(zoom, this.getTileSize());
+    }
+
+    /**
+     * Get the number of rows at a specified zoom level
+     * @param zoom The zoom level
+     * @param tileSize The tile size
+     * @return The number of rows
+     */
+    default int getRows(final int zoom, final int tileSize) {
+        final int height = this.getHeight(zoom);
+        return Math.toIntExact(Math.round(Math.ceil(height / (double) tileSize)));
+    }
+
+    /**
+     * Get the number of columns at a specified zoom level
+     * @param zoom The zoom level
+     * @return The number of columns
+     */
+    default int getColumns(final int zoom) {
+        return this.getColumns(zoom, this.getTileSize());
+    }
+
+    /**
+     * Get the number of columns at a specified zoom level
+     * @param zoom The zoom level
+     * @param tileSize The tile size
+     * @return The number of columns
+     */
+    default int getColumns(final int zoom, final int tileSize) {
+        final int width = this.getWidth(zoom);
+        return Math.toIntExact(Math.round(Math.ceil(width / (double) tileSize)));
+    }
+
+    /**
+     * Get the image to show for a specific tile location. This should be cached by the implementation in most cases.
+     * Top-left corner is 0,0
+     * @param zoom The zoom to use
+     * @param tileSize The tile size to use
+     * @param column The column to get (x)
+     * @param row The row to get (y)
+     * @return The image to display (not padded). May be {@code null}.
+     */
+    Image getTileImage(int zoom, int tileSize, int column, int row);
+
+    /**
+     * Get the image to show for a specific tile location with the default tile size
+     * Top-left corner is 0,0
+     * @param zoom The zoom to use
+     * @param column The column to get (x)
+     * @param row The row to get (y)
+     * @return The image to display (not padded). May be {@code null}.
+     */
+    default Image getTileImage(final int zoom, final int column, final int row) {
+        final String storage = MessageFormat.format("{0}: {1}/{2}/{3}", this, zoom, column, row);
+        BufferedImageCacheEntry image = IMAGE_CACHE.get(storage);
+        if (image == null) {
+            Image newImage = this.getTileImage(zoom, this.getTileSize(), column, row);
+            if (newImage instanceof RenderedImage) {
+                IMAGE_CACHE.put(storage, BufferedImageCacheEntry.pngEncoded((RenderedImage) newImage));
+            }
+            return newImage;
+        }
+        try {
+            return image.getImage();
+        } catch (IOException e) {
+            Logging.error(e);
+        }
+        return null;
+    }
+
+    /**
+     * Get the subsection of the image to show
+     * Top-left corner is 0,0
+     * @param zoom The zoom to use
+     * @param column The column to get (x)
+     * @param row The row to get (y)
+     * @return The subsection of the image to get
+     */
+    default Rectangle getTileDimension(final int zoom, final int column, final int row) {
+        return this.getTileDimension(zoom, column, row, this.getTileSize());
+    }
+
+    /**
+     * Get the subsection of the image to show
+     * Top-left corner is 0,0
+     * @param zoom The zoom to use
+     * @param column The column to get (x)
+     * @param row The row to get (y)
+     * @param tileSize the tile size to use
+     * @return The subsection of the image to get
+     */
+    default Rectangle getTileDimension(final int zoom, final int column, final int row, final int tileSize) {
+        final double scale = this.getScale(zoom); // e.g., 1, 1/2, 1/4, etc.
+        final int x = Math.toIntExact(Math.round(Math.floor(column * tileSize / scale)));
+        final int y = Math.toIntExact(Math.round(Math.floor(row * tileSize / scale)));
+        return new Rectangle(x, y, (int) (tileSize / scale), (int) (tileSize / scale));
+    }
+
+    /**
+     * Get the tiles for a zoom level given a visible rectangle
+     * @param zoom The zoom to get
+     * @param visibleRect The rectangle to get
+     * @return A stream of tiles to images
+     */
+    default Stream<Pair<ImageTile, Image>> getTiles(int zoom, Rectangle visibleRect) {
+        final double scale = this.getScale(zoom);
+        final int startX = Math.toIntExact(Math.round(Math.floor(visibleRect.getMinX() * scale / this.getTileSize())));
+        final int startY = Math.toIntExact(Math.round(Math.floor(visibleRect.getMinY() * scale / this.getTileSize())));
+        final int endX = Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxX() * scale / this.getTileSize())));
+        final int endY = Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxY() * scale / this.getTileSize())));
+        Logging.error("x [{0} - {1}], y[{2} - {3}]", startX, endX, startY, endY);
+        return IntStream.rangeClosed(startX, endX).mapToObj(x -> IntStream.rangeClosed(startY, endY).mapToObj(y -> new ImageTile(x, y, zoom)))
+                .flatMap(stream -> stream).map(tile -> new Pair<>(tile, this.getTileImage(tile.zoom, tile.getXIndex(), tile.getYIndex())));
+    }
+
+    /**
+     * Check if tiling is enabled for this object.
+     *
+     * @return {@code true} if tiling should be u sed
+     */
+    default boolean isTilingEnabled() {
+        return true;
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java b/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java
new file mode 100644
index 0000000000..0ad1096efc
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java
@@ -0,0 +1,143 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.Image;
+import java.awt.image.BufferedImage;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+
+/**
+ * Test class for {@link IImageTiling}
+ * @author Taylor Smock
+ */
+@BasicPreferences
+class IImageTilingTest {
+    static Stream<Arguments> testSizeAtZoom() {
+        return Stream.of(Arguments.of(0, 1L), Arguments.of(1, 4L));
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    void testSizeAtZoom(int zoom, long expected) {
+        assertEquals(expected, IImageTiling.getSizeAtZoom(zoom));
+    }
+
+    static Stream<Arguments> getImageTilingSamples() {
+        return Stream.of(
+                Arguments.of(new ImageTiling(new BufferedImage(5000, 2500, BufferedImage.TYPE_INT_ARGB)), 13)
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetTileSizes(final ImageTiling imageTiling) {
+        // The fake class uses default methods
+        assertEquals(imageTiling.getTileSize(), imageTiling.getDefaultTileSize());
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetMaxZoom(final ImageTiling imageTiling, final int maxZoom) {
+        assertEquals(maxZoom, imageTiling.getMaxZoom());
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetScale(final ImageTiling imageTiling, final int maxZoom) {
+        assertEquals(1, imageTiling.getScale(maxZoom));
+        assertEquals(0.5, imageTiling.getScale(maxZoom - 1));
+        assertEquals(0.25, imageTiling.getScale(maxZoom - 2));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetWidth(final IImageTiling imageTiling, final int maxZoom) {
+        assertEquals(imageTiling.getWidth(), imageTiling.getWidth(maxZoom));
+        assertEquals(imageTiling.getWidth() / 2, imageTiling.getWidth(maxZoom - 1));
+        assertEquals(imageTiling.getWidth() / 4, imageTiling.getWidth(maxZoom - 2));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetHeight(final IImageTiling imageTiling, final int maxZoom) {
+        assertEquals(imageTiling.getHeight(), imageTiling.getHeight(maxZoom));
+        assertEquals(imageTiling.getHeight() / 2, imageTiling.getHeight(maxZoom - 1));
+        assertEquals(imageTiling.getHeight() / 4, imageTiling.getHeight(maxZoom - 2));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetRows(final IImageTiling imageTiling, final int maxZoom) {
+        assertEquals(3, imageTiling.getRows(maxZoom));
+        assertEquals(2, imageTiling.getRows(maxZoom - 1));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetColumns(final IImageTiling imageTiling, final int maxZoom) {
+        assertEquals(5, imageTiling.getColumns(maxZoom));
+        assertEquals(3, imageTiling.getColumns(maxZoom - 1));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetTileImage(final IImageTiling imageTiling, final int maxZoom) {
+        assertNotNull(imageTiling.getTileImage(maxZoom, 0, 0));
+        final Image cornerImage = imageTiling.getTileImage(maxZoom, imageTiling.getColumns(maxZoom) - 1, imageTiling.getRows(maxZoom) - 1);
+        assertAll(() -> assertNotEquals(-1, cornerImage.getWidth(null)),
+                () -> assertNotEquals(-1, cornerImage.getHeight(null)),
+                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getWidth(null)),
+                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getHeight(null)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("getImageTilingSamples")
+    void testGetTileDimension(final IImageTiling imageTiling) {
+        imageTiling.getTileDimension(0, 0, 0);
+    }
+
+    private static class ImageTiling implements IImageTiling {
+        private final int width;
+        private final int height;
+        private final Image image;
+        final AtomicInteger counter = new AtomicInteger(0);
+        ImageTiling(final Image image) {
+            this.image = image;
+            this.width = image.getWidth(null);
+            this.height = image.getHeight(null);
+        }
+
+        @Override
+        public int getWidth() {
+            return this.width;
+        }
+
+        @Override
+        public int getHeight() {
+            return this.height;
+        }
+
+        @Override
+        public Image getTileImage(int zoom, int tileSize, int column, int row) {
+            this.counter.incrementAndGet();
+            if (image instanceof BufferedImage) {
+                final BufferedImage bufferedImage = (BufferedImage) image;
+                return bufferedImage.getSubimage(column * tileSize, row * tileSize,
+                        Math.min(tileSize, bufferedImage.getWidth() - column * tileSize - 1),
+                        Math.min(tileSize, bufferedImage.getHeight() - row * tileSize - 1));
+            }
+            throw new UnsupportedOperationException("The test ImageTiling class only supports BufferedImages");
+        }
+    }
+}
