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..54e8248a52 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;
 
@@ -213,26 +217,68 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         }
 
         public void checkRectPos() {
+            this.checkRectPos(null, 0);
+        }
+
+        /**
+         * Ensure that the rectangle is within bounds
+         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
+         * @param zoom The current zoom level (only used if tiling)
+         */
+        public void checkRectPos(final IImageEntry<?> imageEntry, final int zoom) {
+            final int useWidth;
+            final int useHeight;
+            if (imageEntry instanceof IImageTiling) {
+                useHeight = ((IImageTiling) imageEntry).getHeight(zoom);
+                useWidth = ((IImageTiling) imageEntry).getWidth(zoom);
+            } else {
+                useWidth = init.width;
+                useHeight = init.height;
+            }
             if (x < 0) {
                 x = 0;
             }
             if (y < 0) {
                 y = 0;
             }
-            if (x + width > init.width) {
-                x = init.width - width;
+            if (width > useWidth) {
+                width = useWidth;
+            }
+            if (height > useHeight) {
+                height = useHeight;
             }
-            if (y + height > init.height) {
-                y = init.height - height;
+            if (x + width > useWidth) {
+                x = useWidth - width;
+            }
+            if (y + height > useHeight) {
+                y = useHeight - height;
             }
         }
 
         public void checkRectSize() {
-            if (width > init.width) {
-                width = init.width;
+            this.checkRectSize(null, 0);
+        }
+
+        /**
+         * Ensure that the rectangle is the appropriate size
+         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
+         * @param zoom The current zoom level (only used if tiling)
+         */
+        public void checkRectSize(final IImageEntry<?> imageEntry, final int zoom) {
+            final int useWidth;
+            final int useHeight;
+            if (imageEntry instanceof IImageTiling) {
+                useWidth = ((IImageTiling) imageEntry).getWidth(zoom);
+                useHeight = ((IImageTiling) imageEntry).getHeight(zoom);
+            } else {
+                useWidth = init.width;
+                useHeight = init.height;
+            }
+            if (width > useWidth) {
+                width = useWidth;
             }
-            if (height > init.height) {
-                height = init.height;
+            if (height > useHeight) {
+                height = useHeight;
             }
         }
 
@@ -289,10 +335,14 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                     }
                 }
 
-                int width = img.getWidth();
-                int height = img.getHeight();
-                entry.setWidth(width);
-                entry.setHeight(height);
+                // Only set width/height if the entry is not something that can be tiled
+                // Tiling *requires* knowledge of the actual width/height of the image.
+                if (!(entry instanceof IImageTiling)) {
+                    int width = img.getWidth();
+                    int height = img.getHeight();
+                    entry.setWidth(width);
+                    entry.setHeight(height);
+                }
 
                 synchronized (ImageDisplay.this) {
                     if (this.entry != ImageDisplay.this.entry) {
@@ -304,7 +354,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                     updateProcessedImage();
                     // This will clear the loading info box
                     ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
-                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
+                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image, this.entry);
 
                     selectedRect = null;
                     errorLoading = false;
@@ -358,34 +408,47 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
 
             // Calculate the mouse cursor position in image coordinates to center the zoom.
             if (refreshMousePointInImg)
-                mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
+                mousePointInImg = comp2imgCoord(currentEntry, currentVisibleRect, x, y, getSize(), zoom.get());
 
             // Apply the zoom to the visible rectangle in image coordinates
+            final int zoom;
             if (rotation > 0) {
                 currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
                 currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
+                zoom = ImageDisplay.this.zoom.decrementAndGet();
             } else {
                 currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
                 currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
+                zoom = ImageDisplay.this.zoom.incrementAndGet();
             }
 
             // Check that the zoom doesn't exceed MAX_ZOOM:1
             ensureMaxZoom(currentVisibleRect);
 
             // The size of the visible rectangle is limited by the image size or the viewer implementation.
+            // It can also be influenced by whether or not the current entry allows tiling. Tiling image implementations
+            // don't care what the size of the image buffer is. In fact, the image buffer can be the size of the window.
+            // So the image buffer really only defines the scale.
             if (imageViewer != null) {
-                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
+                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentEntry, currentVisibleRect);
             } else {
-                currentVisibleRect.checkRectSize();
+                currentVisibleRect.checkRectSize(currentEntry, zoom);
             }
 
             // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
-            Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
+            final Rectangle drawRect;
+            if (currentEntry instanceof IImageTiling) {
+                final byte multiplyBy = rotation > 0 ? (byte) -2 : (byte) 2;
+                drawRect = new VisRect(currentVisibleRect.x * multiplyBy, currentVisibleRect.y * multiplyBy,
+                        currentVisibleRect.width * multiplyBy, currentVisibleRect.height * multiplyBy);
+            } else {
+                drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
+            }
             currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
             currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
 
             // The position is also limited by the image size
-            currentVisibleRect.checkRectPos();
+            currentVisibleRect.checkRectPos(currentEntry, zoom);
 
             synchronized (ImageDisplay.this) {
                 if (ImageDisplay.this.entry == currentEntry) {
@@ -447,13 +510,13 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             }
 
             // Calculate the translation to set the clicked point the center of the view.
-            Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+            Point click = comp2imgCoord(currentEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
             Point center = getCenterImgCoord(currentVisibleRect);
 
             currentVisibleRect.x += click.x - center.x;
             currentVisibleRect.y += click.y - center.y;
 
-            currentVisibleRect.checkRectPos();
+            currentVisibleRect.checkRectPos(currentEntry, ImageDisplay.this.zoom.get());
 
             synchronized (ImageDisplay.this) {
                 if (ImageDisplay.this.entry == currentEntry) {
@@ -467,12 +530,14 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
          * a picture part) */
         @Override
         public void mousePressed(MouseEvent e) {
-            Image currentImage;
-            VisRect currentVisibleRect;
+            final Image currentImage;
+            final VisRect currentVisibleRect;
+            final IImageEntry<?> imageEntry;
 
             synchronized (ImageDisplay.this) {
                 currentImage = ImageDisplay.this.image;
                 currentVisibleRect = ImageDisplay.this.visibleRect;
+                imageEntry = ImageDisplay.this.entry;
             }
 
             if (currentImage == null)
@@ -481,7 +546,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             selectedRect = null;
 
             if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
-                mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+                mousePointInImg = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
         }
 
         @Override
@@ -503,9 +568,9 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                 return;
 
             if (mouseIsDragging(e) && mousePointInImg != null) {
-                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
                 getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
-                currentVisibleRect.checkRectPos();
+                currentVisibleRect.checkRectPos(imageEntry, ImageDisplay.this.zoom.get());
                 synchronized (ImageDisplay.this) {
                     if (ImageDisplay.this.entry == imageEntry) {
                         ImageDisplay.this.visibleRect = currentVisibleRect;
@@ -514,7 +579,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                 // We have to update the mousePointInImg for 360 image panning, as otherwise the panning never stops.
                 // This does not work well with the perspective viewer at this time (2021-08-26).
                 boolean is360panning = entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType();
-                if (is360panning) {
+                if (is360panning || entry instanceof IImageTiling) {
                     this.mousePointInImg = p;
                 }
                 ImageDisplay.this.repaint();
@@ -525,7 +590,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             }
 
             if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
-                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
                 currentVisibleRect.checkPointInside(p);
                 VisRect selectedRectTemp = new VisRect(
                         Math.min(p.x, mousePointInImg.x),
@@ -533,8 +598,8 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                         p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
                         p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
                         currentVisibleRect);
-                selectedRectTemp.checkRectSize();
-                selectedRectTemp.checkRectPos();
+                selectedRectTemp.checkRectSize(imageEntry, zoom.get());
+                selectedRectTemp.checkRectPos(imageEntry, zoom.get());
                 ImageDisplay.this.selectedRect = selectedRectTemp;
                 ImageDisplay.this.repaint();
             }
@@ -574,8 +639,8 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                     selectedRect.y -= (selectedRect.height - oldHeight) / 2;
                 }
 
-                selectedRect.checkRectSize();
-                selectedRect.checkRectPos();
+                selectedRect.checkRectSize(currentEntry, zoom.get());
+                selectedRect.checkRectPos(currentEntry, zoom.get());
             }
 
             synchronized (ImageDisplay.this) {
@@ -719,7 +784,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());
@@ -826,10 +895,10 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
      */
     private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
         if (selectedRect != null) {
-            Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
-            Point bottomRight = img2compCoord(visibleRectTemp,
+            Point topLeft = img2compCoord(entry, visibleRectTemp, selectedRect.x, selectedRect.y, size, zoom.get());
+            Point bottomRight = img2compCoord(entry, visibleRectTemp,
                     selectedRect.x + selectedRect.width,
-                    selectedRect.y + selectedRect.height, size);
+                    selectedRect.y + selectedRect.height, size, zoom.get());
             g.setColor(new Color(128, 128, 128, 180));
             g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
             g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
@@ -840,14 +909,44 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         }
     }
 
-    static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
-        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
+    /**
+     * Convert an image coordinate to a component coordinate
+     * @param imageEntry The image entry -- only used if tiling
+     * @param visibleRect The visible rectangle
+     * @param xImg The x position in the component
+     * @param yImg The y position in the component
+     * @param compSize The component size
+     * @param zoom The current zoom level
+     * @return The point in the image
+     */
+    static Point img2compCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xImg, int yImg, Dimension compSize, int zoom) {
+        final Rectangle drawRect;
+        if (imageEntry instanceof IImageTiling) {
+            drawRect = visibleRect;
+        } else {
+            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
+        }
         return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
                 drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
     }
 
-    static Point comp2imgCoord(VisRect visibleRect, int xComp, int yComp, Dimension compSize) {
-        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
+    /**
+     * Convert a component coordinate to an image coordinate
+     * @param imageEntry The image entry -- only used if tiling
+     * @param visibleRect The visible rectangle
+     * @param xComp The x position in the component
+     * @param yComp The y position in the component
+     * @param compSize The component size
+     * @param zoom The current zoom level
+     * @return The point in the image
+     */
+    static Point comp2imgCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xComp, int yComp, Dimension compSize, int zoom) {
+        final Rectangle drawRect;
+        if (imageEntry instanceof IImageTiling) {
+            drawRect = visibleRect;
+        } else {
+            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
+        }
         Point p = new Point(
                         ((xComp - drawRect.x) * visibleRect.width),
                         ((yComp - drawRect.y) * visibleRect.height));
@@ -944,8 +1043,8 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             Point center = getCenterImgCoord(currentVisibleRect);
             currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
                     getWidth(), getHeight());
-            currentVisibleRect.checkRectSize();
-            currentVisibleRect.checkRectPos();
+            currentVisibleRect.checkRectSize(currentEntry, this.zoom.get());
+            currentVisibleRect.checkRectPos(currentEntry, this.zoom.get());
         }
 
         synchronized (this) {
@@ -1017,6 +1116,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());
+            }
+        }
     }
 
     /**
@@ -1027,19 +1139,21 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
     public void updateVisibleRectangle() {
         final VisRect currentVisibleRect;
         final Image mouseImage;
-        final IImageViewer iImageViewer;
+        final IImageViewer currentImageViewer;
+        final IImageEntry<?> imageEntry;
         synchronized (this) {
             currentVisibleRect = this.visibleRect;
             mouseImage = this.image;
-            iImageViewer = this.getIImageViewer(this.entry);
+            imageEntry = this.entry;
+            currentImageViewer = this.getIImageViewer(imageEntry);
         }
-        if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
-            final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
+        if (mouseImage != null && currentVisibleRect != null && currentImageViewer != null) {
+            final Image maxImageSize = currentImageViewer.getMaxImageSize(this, mouseImage);
             final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
             maxVisibleRect.setRect(currentVisibleRect);
             ensureMaxZoom(maxVisibleRect);
 
-            maxVisibleRect.checkRectSize();
+            maxVisibleRect.checkRectSize(imageEntry, this.zoom.get());
             synchronized (this) {
                 this.visibleRect = maxVisibleRect;
             }
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
index d5a94886b1..082496e1d4 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) {
+        final Rectangle tile = IImageTiling.super.getTileDimension(zoom, column, row, tileSize);
+        if (column < 0 || row < 0 || zoom > this.getMaxZoom() || tile.getWidth() <= 0 || tile.getHeight() <= 0) {
+            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, tile, zoom));
+        } 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, final Rectangle tile, int zoom) {
+        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();
@@ -247,4 +284,10 @@ public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>
         g.dispose();
         return rotated;
     }
+
+    @Override
+    public boolean isTilingEnabled() {
+        // Flipped images are going to need more work (the column/rows will need to be translated)
+        return IImageTiling.super.isTilingEnabled() && !ExifReader.orientationNeedsCorrection(getExifOrientation());
+    }
 }
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..7f9a05a42d 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
@@ -10,8 +10,10 @@ import java.awt.event.ComponentListener;
 import java.awt.image.BufferedImage;
 import java.util.Set;
 
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
 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 +36,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).forEachOrdered(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
@@ -42,6 +69,17 @@ public interface IImageViewer extends ComponentListener {
      */
     ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image);
 
+    /**
+     * Get the default visible rectangle for the projection and entry
+     * @param component The component the image will be displayed in
+     * @param image The image that will be shown
+     * @param entry The entry that will be used
+     * @return The default visible rectangle
+     */
+    default ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
+        return this.getDefaultVisibleRectangle(component, image);
+    }
+
     /**
      * Get the current rotation in the image viewer
      * @return The rotation
@@ -77,6 +115,38 @@ public interface IImageViewer extends ComponentListener {
         }
     }
 
+    /**
+     * Check and modify the visible rect size to appropriate dimensions
+     * @param visibleRect the visible rectangle to update
+     * @param entry the entry to use for checking
+     * @param image The image to use for checking
+     */
+    default void checkAndModifyVisibleRectSize(Image image, IImageEntry<?> entry, ImageDisplay.VisRect visibleRect) {
+        if (entry instanceof IImageTiling) {
+            final IImageTiling tiling = (IImageTiling) entry;
+            if (visibleRect.width > tiling.getWidth()) {
+                visibleRect.width = tiling.getWidth();
+            }
+            if (visibleRect.height > tiling.getHeight()) {
+                visibleRect.height = tiling.getHeight();
+            }
+            if (visibleRect.x + visibleRect.width > tiling.getWidth()) {
+                visibleRect.x = tiling.getWidth() - visibleRect.width;
+            }
+            if (visibleRect.y + visibleRect.height > tiling.getHeight()) {
+                visibleRect.y = tiling.getHeight() - visibleRect.height;
+            }
+            if (visibleRect.x < 0) {
+                visibleRect.x = 0;
+            }
+            if (visibleRect.y < 0) {
+                visibleRect.y = 0;
+            }
+        } else {
+            this.checkAndModifyVisibleRectSize(image, visibleRect);
+        }
+    }
+
     /**
      * Get the maximum image size that can be displayed
      * @param imageDisplay The image display
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
index d570c53dde..08d4add18b 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
@@ -10,8 +10,10 @@ import java.awt.image.BufferedImage;
 import java.util.EnumSet;
 import java.util.Set;
 
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
 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;
 
 /**
  * The default perspective image viewer class.
@@ -36,4 +38,12 @@ public class Perspective extends ComponentAdapter implements IImageViewer {
     public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
         return new ImageDisplay.VisRect(0, 0, image.getWidth(null), image.getHeight(null));
     }
+
+    @Override
+    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
+        if (entry instanceof IImageTiling) {
+            return new ImageDisplay.VisRect(0, 0, ((IImageTiling) entry).getWidth(), ((IImageTiling) entry).getHeight());
+        }
+        return IImageViewer.super.getDefaultVisibleRectangle(component, image, entry);
+    }
 }
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..84ab763822
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
@@ -0,0 +1,285 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.awt.Dimension;
+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.DoubleToIntFunction;
+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 size of the image
+     * @return The image size at the zoom level
+     */
+    default Dimension getSize(int zoom) {
+        return new Dimension(this.getWidth(zoom), this.getHeight(zoom));
+    }
+
+    /**
+     * 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 DoubleToIntFunction roundToInt = dbl -> Math.toIntExact(Math.round(Math.floor(dbl)));
+        final int x = roundToInt.applyAsInt(column * tileSize / scale);
+        final int y = roundToInt.applyAsInt(row * tileSize / scale);
+        final int defaultDimension = roundToInt.applyAsInt(tileSize / scale);
+        final int width = Math.min(defaultDimension, roundToInt.applyAsInt(this.getWidth() - column * tileSize / scale));
+        final int height = Math.min(defaultDimension, roundToInt.applyAsInt(this.getHeight() - row * tileSize / scale));
+        return new Rectangle(x, y, width, height);
+    }
+
+    /**
+     * 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 (may be parallel)
+     */
+    default Stream<Pair<ImageTile, Image>> getTiles(int zoom, Rectangle visibleRect) {
+        // We very specifically want to "overscan" -- this fixes some issues where the image isn't fully loaded
+        final int startX = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinX() / this.getTileSize()))) - 1);
+        final int startY = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinY() / this.getTileSize()))) - 1);
+        final int endX = Math.min(this.getColumns(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxX() / this.getTileSize()))) + 1);
+        final int endY = Math.min(this.getRows(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxY() / this.getTileSize()))) + 1);
+        return IntStream.rangeClosed(startX, endX).mapToObj(x -> IntStream.rangeClosed(startY, endY).mapToObj(y -> new ImageTile(x, y, zoom)))
+                .flatMap(stream -> stream).parallel().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");
+        }
+    }
+}
