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/data/cache/JCSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
index f8603239a9..7512641aa9 100644
--- a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
@@ -99,6 +99,16 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
     private boolean force;
     private final long minimumExpiryTime;
 
+    /**
+     * Get the deduplication string for this job
+     * @return The string used for deduplication
+     * @throws IOException See {@link #getUrl()}
+     */
+    private String getDeduplicationString() throws IOException {
+        // getCacheKey is useful for where the same url might return different items
+        return this.getUrl().toString() + '/' + getCacheKey();
+    }
+
     /**
      * @param cache cache instance that we will work on
      * @param options options of the request
@@ -150,7 +160,7 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
         String deduplicationKey = null;
         if (url != null) {
             // url might be null, for example when Bing Attribution is not loaded yet
-            deduplicationKey = url.toString();
+            deduplicationKey = this.getDeduplicationString();
         }
         if (deduplicationKey == null) {
             Logging.warn("No url returned for: {0}, skipping", getCacheKey());
@@ -252,7 +262,7 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
     private void finishLoading(LoadResult result) {
         Set<ICachedLoaderListener> listeners;
         try {
-            listeners = inProgress.remove(getUrl().toString());
+            listeners = inProgress.remove(this.getDeduplicationString());
         } catch (IOException e) {
             listeners = null;
             Logging.trace(e);
@@ -319,8 +329,8 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
         if (!file.exists()) {
             file = new File(fileName.substring("file://".length() - 1));
         }
-        try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
-            cacheData = createCacheEntry(Utils.readBytesFromStream(fileInputStream));
+        try {
+            cacheData = createCacheEntry(this.loadObjectBytes(file));
             cache.put(getCacheKey(), cacheData, attributes);
             return true;
         } catch (IOException e) {
@@ -331,6 +341,18 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
         return false;
     }
 
+    /**
+     * Load bytes from a file. This is overridable to allow for sparse loading of bytes
+     * @param file The file to load
+     * @return The file bytes
+     * @throws IOException If there is an issue reading the file
+     */
+    protected byte[] loadObjectBytes(File file) throws IOException {
+        try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
+            return Utils.readBytesFromStream(fileInputStream);
+        }
+    }
+
     /**
      * @return true if object was successfully downloaded via http, false, if there was a loading failure
      */
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
index 418fcfd520..6c5a91dee8 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;
@@ -79,7 +81,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
     private boolean errorLoading;
 
     /** The rectangle (in image coordinates) of the image that is visible. This rectangle is calculated
-     * each time the zoom is modified */
+     * each time the zoom is modified. Note: This is in the current zoom coordinates, if those change. */
     private VisRect visibleRect;
 
     /** When a selection is done, the rectangle of the selection (in image coordinates) */
@@ -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,69 @@ 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) {
+                final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
+                useHeight = tiler.getHeight(zoom);
+                useWidth = tiler.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 (y + height > init.height) {
-                y = init.height - height;
+            if (height > useHeight) {
+                height = useHeight;
+            }
+            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;
             }
         }
 
@@ -265,6 +312,36 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             VisRect other = (VisRect) obj;
             return Objects.equals(init, other.init);
         }
+
+        /**
+         * Create an equivalent rectangle in image coordinates
+         * @param size The component size
+         * @param visibleRect The visible rectangle
+         * @param imageEntry The current image entry
+         * @param zoom The current zoom
+         * @return A copy of this rectangle in image coordinates
+         */
+        VisRect createImageCoordinateRectangle(Dimension size, VisRect visibleRect, IImageEntry<?> imageEntry, int zoom) {
+            final Point imageUpperLeft = comp2imgCoord(imageEntry, visibleRect, this.x,
+                    this.y, size, zoom);
+            final Point imageLowerRight = comp2imgCoord(imageEntry, visibleRect,
+                    this.x + this.width, this.y + this.height, size, zoom);
+            return new VisRect(imageUpperLeft.x, imageUpperLeft.y, imageLowerRight.x - imageUpperLeft.x, imageLowerRight.y - imageUpperLeft.y);
+        }
+
+        /**
+         * Create an equivalent rectangle in component coordinates
+         * @param size The component size
+         * @param visibleRect The visible rectangle
+         * @param imageEntry The current image entry
+         * @param zoom The current zoom
+         * @return A copy of this rectangle in component coordinates
+         */
+        VisRect createCompCoordinateRectangle(Dimension size, VisRect visibleRect, IImageEntry<?> imageEntry, int zoom) {
+            final Point compUpperLeft = img2compCoord(imageEntry, visibleRect, this.x, this.y, size, zoom);
+            final Point compLowerRight = img2compCoord(imageEntry, visibleRect, this.x + this.width, this.y + this.height, size, zoom);
+            return new VisRect(compUpperLeft.x, compUpperLeft.y, compLowerRight.x - compUpperLeft.x, compLowerRight.y - compUpperLeft.y);
+        }
     }
 
     /** The thread that reads the images. */
@@ -289,10 +366,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) {
@@ -301,10 +382,13 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                     }
 
                     ImageDisplay.this.image = img;
-                    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);
+                    // Update the visible rectangle
+                    ImageDisplay.this.updateVisibleRectangle();
+                    // Update the processed image *after* updating the visible rect -- otherwise, we may try to load a large image fully (tiled)
+                    ImageDisplay.this.updateProcessedImage();
 
                     selectedRect = null;
                     errorLoading = false;
@@ -319,6 +403,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
     private class ImgDisplayMouseListener extends MouseAdapter {
 
         private MouseEvent lastMouseEvent;
+        /** The mouse point in image coordinates in the image */
         private Point mousePointInImg;
 
         private boolean mouseIsDragging(MouseEvent e) {
@@ -358,34 +443,65 @@ 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());
+                this.mousePointInImg = comp2imgCoord(currentEntry, currentVisibleRect, x, y, getSize(), zoom.get());
 
             // Apply the zoom to the visible rectangle in image coordinates
+            final int oldZoom = ImageDisplay.this.zoom.get();
+            final double step;
             if (rotation > 0) {
-                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
-                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
+                final int currentZoom = ImageDisplay.this.zoom.decrementAndGet();
+                step = currentEntry instanceof IImageTiling ? Math.pow(2, oldZoom - currentZoom) : ZOOM_STEP.get();
             } else {
-                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
-                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
+                final int currentZoom = ImageDisplay.this.zoom.incrementAndGet();
+                step = currentEntry instanceof IImageTiling ? Math.pow(2, oldZoom - currentZoom) : 1 / ZOOM_STEP.get();
+            }
+            final VisRect oldVisibleRectImageCoordinates = currentVisibleRect.createImageCoordinateRectangle(getSize(),
+                    currentVisibleRect, currentEntry, oldZoom);
+            // This ensures that zoom is not out of bounds
+            ensureMaxZoom(currentVisibleRect);
+            final int currentZoom = ImageDisplay.this.zoom.get();
+            // If the zoom doesn't change, stop
+            if (currentZoom == oldZoom) {
+                return;
             }
+            currentVisibleRect.width = (int) (currentVisibleRect.width * step);
+            currentVisibleRect.height = (int) (currentVisibleRect.height * step);
 
             // Check that the zoom doesn't exceed MAX_ZOOM:1
-            ensureMaxZoom(currentVisibleRect);
+            if (!(currentEntry instanceof IImageTiling) || currentZoom > ((IImageTiling<?>) currentEntry).getMaxZoom()) {
+                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, currentZoom);
             }
 
             // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
-            Rectangle 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;
+            if (currentEntry instanceof IImageTiling) {
+                final Point pointToUse = mousePointInImg;
+                // Create an equivalent visible rectangle, just in image coordinates instead of image zoom coordinates
+                final VisRect imageVisRect = currentVisibleRect.createImageCoordinateRectangle(getSize(),
+                        oldVisibleRectImageCoordinates, currentEntry, oldZoom);
+                imageVisRect.x = pointToUse.x +
+                        ((oldVisibleRectImageCoordinates.x - pointToUse.x) * imageVisRect.width) /oldVisibleRectImageCoordinates.width;
+                imageVisRect.y = pointToUse.y +
+                        ((oldVisibleRectImageCoordinates.y - pointToUse.y) * imageVisRect.height) / oldVisibleRectImageCoordinates.height;
+                // Convert the equivalent visible rectangle back to image zoom coordinates
+                currentVisibleRect = imageVisRect.createCompCoordinateRectangle(getSize(), currentVisibleRect, currentEntry, currentZoom);
+            } else {
+                final VisRect 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, currentZoom);
 
             synchronized (ImageDisplay.this) {
                 if (ImageDisplay.this.entry == currentEntry) {
@@ -447,13 +563,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 +583,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 +599,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 +621,14 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                 return;
 
             if (mouseIsDragging(e) && mousePointInImg != null) {
-                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
-                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
-                currentVisibleRect.checkRectPos();
+                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
+                // Create an equivalent visible rectangle, just in image coordinates instead of image zoom coordinates
+                final VisRect imageVisRect = currentVisibleRect.createImageCoordinateRectangle(getSize(), currentVisibleRect,
+                        imageEntry, zoom.get());
+                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, imageVisRect);
+                // Convert the equivalent visible rectangle back to image zoom coordinates
+                currentVisibleRect = imageVisRect.createCompCoordinateRectangle(getSize(), currentVisibleRect, imageEntry, zoom.get());
+                currentVisibleRect.checkRectPos(imageEntry, ImageDisplay.this.zoom.get());
                 synchronized (ImageDisplay.this) {
                     if (ImageDisplay.this.entry == imageEntry) {
                         ImageDisplay.this.visibleRect = currentVisibleRect;
@@ -525,7 +648,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 +656,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 +697,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) {
@@ -642,6 +765,9 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         synchronized (this) {
             this.oldEntry = this.entry;
             this.entry = entry;
+            if (entry instanceof IImageTiling) {
+                this.zoom.set(((IImageTiling<?>) entry).getMinZoom() + 1);
+            }
             if (entry == null) {
                 image = null;
                 updateProcessedImage();
@@ -719,7 +845,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(), this);
+            } 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 +956,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 +970,50 @@ 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) {
+            final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
+            final double scale = tiler.getScale(zoom);
+            // xImg = (visRect.x + xComp) / scale
+            // xImg * scale - visRect.x = xComp
+            return new Point((int) (xImg * scale - visibleRect.x), (int) (yImg * scale - visibleRect.y));
+        } 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) {
+            final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
+            final double scale = tiler.getScale(zoom);
+            return new Point((int) ((visibleRect.x + xComp) / scale), (int) ((visibleRect.y + yComp) / scale));
+        } else {
+            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
+        }
         Point p = new Point(
                         ((xComp - drawRect.x) * visibleRect.width),
                         ((yComp - drawRect.y) * visibleRect.height));
@@ -944,8 +1110,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) {
@@ -1009,13 +1175,32 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
         }
 
-        // Set the same ratio for the visible rectangle and the display area
-        int hFact = rectangle.height * getSize().width;
-        int wFact = rectangle.width * getSize().height;
-        if (hFact > wFact) {
-            rectangle.width = hFact / getSize().height;
+
+        final IImageEntry<?> currentEntry;
+        synchronized (this) {
+            currentEntry = this.entry;
+        }
+        if (currentEntry instanceof IImageTiling) {
+            final int currentZoom = this.zoom.get();
+            IImageTiling<?> imageTiling = (IImageTiling<?>) currentEntry;
+            if (currentZoom > imageTiling.getMaxZoom()) {
+                this.zoom.set(imageTiling.getMaxZoom());
+            } else if (currentZoom < imageTiling.getMinZoom()) {
+                this.zoom.set(imageTiling.getMinZoom());
+            } else if (getSize().width > imageTiling.getWidth(currentZoom) * 2
+                && getSize().height > imageTiling.getHeight(currentZoom) * 2) {
+                // Don't let users make the image really small in the window
+                this.zoom.set(currentZoom + 1);
+            }
         } else {
-            rectangle.height = wFact / getSize().width;
+            // Set the same ratio for the visible rectangle and the display area
+            int hFact = rectangle.height * getSize().width;
+            int wFact = rectangle.width * getSize().height;
+            if (hFact > wFact) {
+                rectangle.width = hFact / getSize().height;
+            } else {
+                rectangle.height = wFact / getSize().width;
+            }
         }
     }
 
@@ -1027,19 +1212,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 7ea6371f62..4170d07a04 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,8 @@ 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.DeepTileSet;
+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;
@@ -31,10 +35,11 @@ import org.openstreetmap.josm.tools.Utils;
  * 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<BufferedImage> {
 
     private Image thumbnail;
     private ImageData dataSet;
+    private DeepTileSet deepTileSet;
 
     /**
      * Constructs a new {@code ImageEntry}.
@@ -51,6 +56,7 @@ public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>
         super(other);
         thumbnail = other.thumbnail;
         dataSet = other.dataSet;
+        this.deepTileSet = other.deepTileSet;
     }
 
     /**
@@ -223,10 +229,44 @@ public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>
         return applyExifRotation(image);
     }
 
+    @Override
+    public BufferedImage 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();
@@ -258,4 +298,18 @@ public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>
         g.dispose();
         return rotated;
     }
+
+    @Override
+    public DeepTileSet getDeepTileSet() {
+        if (this.deepTileSet == null) {
+            this.deepTileSet = new DeepTileSet(this.getMinZoom(), this.getMaxZoom(), this);
+        }
+        return this.deepTileSet;
+    }
+
+    @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/Equirectangular.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
index f76b252344..0a5467342b 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
@@ -12,8 +12,11 @@ import java.awt.image.BufferedImage;
 import java.util.Collections;
 import java.util.Set;
 
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
 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.GeoImageTileLoader;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
 import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.gui.util.imagery.CameraPlane;
 import org.openstreetmap.josm.gui.util.imagery.Vector3D;
@@ -24,6 +27,7 @@ import org.openstreetmap.josm.gui.util.imagery.Vector3D;
  * @since 18246
  */
 public class Equirectangular extends ComponentAdapter implements IImageViewer {
+    private final GeoImageTileLoader tileLoader = new GeoImageTileLoader(null, IImageTiling.IMAGE_CACHE);
     private volatile CameraPlane cameraPlane;
     private volatile BufferedImage offscreenImage;
 
@@ -108,4 +112,9 @@ public class Equirectangular extends ComponentAdapter implements IImageViewer {
     public Image getMaxImageSize(ImageDisplay imageDisplay, Image image) {
         return this.offscreenImage;
     }
+
+    @Override
+    public TileLoader getTileLoader() {
+        return this.tileLoader;
+    }
 }
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..c9edb55163 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
@@ -8,10 +8,22 @@ import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.event.ComponentListener;
 import java.awt.image.BufferedImage;
+import java.util.List;
 import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+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.GeoImageTileLoader;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.TileSet;
 import org.openstreetmap.josm.gui.util.imagery.Vector3D;
 
 /**
@@ -19,6 +31,19 @@ import org.openstreetmap.josm.gui.util.imagery.Vector3D;
  * @since 18246
  */
 public interface IImageViewer extends ComponentListener {
+    /**
+     * A class for {@link IImageViewer}. Probably shouldn't be used elsewhere.
+     */
+    class ImageTimerTask {
+        /** A timer so that we aren't doing many repaints in a short time frame */
+        static final Timer REPAINT_TIMER = new Timer("IImageViewerTimer");
+        /** The current timer task */
+        static TimerTask timerTask;
+        private ImageTimerTask() {
+            // Hide constructor
+        }
+    }
+
     /**
      * Get the supported projections for the image viewer
      * @return The projections supported. Typically, only one.
@@ -34,6 +59,101 @@ 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 visibleRect The visible area
+     * @param entry The image entry (specifically, with the tile size)
+     * @param tile The tile to paint (x, y, z)
+     * @param zoom The current zoom
+     */
+    default void paintImageTile(final Graphics g, final Rectangle visibleRect, final IImageTiling<?> entry, final Tile tile, final int zoom) {
+        final int tileSize = entry.getTileSize();
+        final Image image;
+        int xPositionInImage = tileSize * tile.getXtile();
+        int yPositionInImage = tileSize * tile.getYtile();
+        if (zoom == tile.getZoom()) {
+            image = tile.getImage();
+        } else {
+            // When the zooms are not the same, we have to scale the image appropriately
+            final double scalingFactor = Math.pow(2, (double) zoom - tile.getZoom());
+            yPositionInImage *= scalingFactor;
+            xPositionInImage *= scalingFactor;
+            final Image tileImage = tile.getImage();
+            image = tileImage.getScaledInstance((int) (tileImage.getWidth(null) * scalingFactor),
+                    (int) (tileImage.getHeight(null) * scalingFactor), Image.SCALE_DEFAULT);
+        }
+        final int x = xPositionInImage - visibleRect.x;
+        final int y = yPositionInImage - visibleRect.y;
+        g.drawImage(image, x, y, 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
+     * @param component The component to repaint when tiles are loaded
+     */
+    default void paintTiledImage(Graphics g, IImageTiling<?> imageEntry, Rectangle target, Rectangle visibleRect,
+            int zoom, Component component) {
+        if (this.getTileLoader() instanceof GeoImageTileLoader) {
+            ((GeoImageTileLoader) this.getTileLoader()).listener = (tile, success) -> updateRepaintTimer(component);
+        }
+        final Predicate<Tile> paintableTile = tile -> tile.isLoaded() && !tile.hasError();
+        final Predicate<Tile> missingTile = tile -> !tile.isLoaded() && !tile.isLoading();
+        final List<Tile> tiles = imageEntry.getTiles(zoom, visibleRect).collect(Collectors.toList());
+        final List<Tile> missed = tiles.stream().filter(missingTile).collect(Collectors.toList());
+        // Attempt to paint tiles at a lower zoom that are already loaded.
+        if (zoom > imageEntry.getMinZoom()) {
+            final List<Tile> superToPaint = missed.stream().map(tile -> imageEntry.getCoveringTileRange(tile, zoom - 1))
+                    .distinct().flatMap(TileSet::allTiles).distinct().collect(Collectors.toList());
+            // Paint lower resolution filler first
+            superToPaint.forEach(tile -> tiles.add(0, tile));
+            // Add any unloaded lower resolution tiles to the missed tiles. We are probably going to use them as the
+            // user pans around.
+            superToPaint.stream().filter(missingTile).forEach(missed::add);
+        }
+        // Attempt to paint tiles at a higher zoom that are already loaded
+        if (zoom < imageEntry.getMaxZoom()) {
+            final List<Tile> subsetToPaint = missed.stream().map(tile -> imageEntry.getCoveringTileRange(tile, zoom + 1))
+                    .distinct().flatMap(TileSet::allTiles).distinct().collect(Collectors.toList());
+            // Paint higher resolution filler last
+            tiles.addAll(subsetToPaint);
+        }
+        // Paint the tiles that are loaded on this layer
+        tiles.stream().filter(paintableTile).forEach(tile -> this.paintImageTile(g, visibleRect, imageEntry, tile, zoom));
+        // Start loading tiles that have yet to be loaded.
+        missed.stream().map(this.getTileLoader()::createTileLoaderJob).forEach(TileJob::submit);
+    }
+
+    /**
+     * Update the common repaint timer
+     * @param component The component to update
+     */
+    static void updateRepaintTimer(Component component) {
+        synchronized (ImageTimerTask.REPAINT_TIMER) {
+            if (ImageTimerTask.timerTask != null) {
+                ImageTimerTask.timerTask.cancel();
+            }
+            ImageTimerTask.timerTask = new TimerTask() {
+                @Override
+                public void run() {
+                    component.repaint();
+                }
+            };
+            ImageTimerTask.REPAINT_TIMER.schedule(ImageTimerTask.timerTask, 100);
+        }
+    }
+
+    /**
+     * Get the tile loader for this image
+     * @return The tile loader.
+     */
+    TileLoader getTileLoader();
+
     /**
      * Get the default visible rectangle for the projection
      * @param component The component the image will be displayed in
@@ -42,6 +162,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 +208,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..b2a721dae1 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,12 @@ import java.awt.image.BufferedImage;
 import java.util.EnumSet;
 import java.util.Set;
 
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+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.GeoImageTileLoader;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
 
 /**
  * The default perspective image viewer class.
@@ -19,7 +23,7 @@ import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
  * @since 18246
  */
 public class Perspective extends ComponentAdapter implements IImageViewer {
-
+    private final GeoImageTileLoader tileLoader = new GeoImageTileLoader(null, IImageTiling.IMAGE_CACHE);
     @Override
     public Set<Projections> getSupportedProjections() {
         return EnumSet.of(Projections.PERSPECTIVE);
@@ -32,8 +36,21 @@ public class Perspective extends ComponentAdapter implements IImageViewer {
                 r.x, r.y, r.x + r.width, r.y + r.height, null);
     }
 
+    @Override
+    public TileLoader getTileLoader() {
+        return this.tileLoader;
+    }
+
     @Override
     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/DeepTileSet.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/DeepTileSet.java
new file mode 100644
index 0000000000..8532f936a3
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/DeepTileSet.java
@@ -0,0 +1,149 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
+
+/**
+ * A collection of caching tile sets
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class DeepTileSet implements Set<TileSet> {
+    private final TileCache memoryTileCache = new MemoryTileCache();
+
+    private final int minZoom;
+    private final int maxZoom;
+    private final TileSet[] tileSets;
+    private final TileSet nullTileSet = new TileSet();
+    private final IImageTiling<?> imageTiling;
+
+    public DeepTileSet(final int minZoom, final int maxZoom, final IImageTiling<?> imageTiling) {
+        this.minZoom = minZoom;
+        this.maxZoom = maxZoom;
+        if (minZoom > maxZoom) {
+            throw new IllegalArgumentException(minZoom + " > " + maxZoom);
+        }
+        this.tileSets = new TileSet[maxZoom - minZoom + 1];
+        this.imageTiling = imageTiling;
+    }
+
+    public TileSet getTileSet(int zoom) {
+        if (zoom < minZoom) {
+            return nullTileSet;
+        } else if (zoom > maxZoom) {
+            zoom = maxZoom;
+        }
+        synchronized (tileSets) {
+            TileSet ts = tileSets[zoom-minZoom];
+            if (ts == null) {
+                ts = new TileSet(new TileXY(0, 0),
+                        new TileXY(this.imageTiling.getTileXMax(zoom), this.imageTiling.getTileYMax(zoom)),
+                        zoom, this.memoryTileCache, this.imageTiling);
+                ts.allTilesCreate();
+                tileSets[zoom-minZoom] = ts;
+            }
+            return ts;
+        }
+    }
+
+    /**
+     * Get the tile cache for this deep tile set
+     * @return The tile cache
+     */
+    TileCache getTileCache() {
+        return this.memoryTileCache;
+    }
+
+    @Override
+    public int size() {
+        return Math.toIntExact(Stream.of(this.tileSets).filter(Objects::nonNull).count());
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return Stream.of(this.tileSets).allMatch(Objects::isNull);
+    }
+
+    @Override
+    public boolean contains(Object o) {
+        Objects.requireNonNull(o);
+        return Arrays.asList(this.tileSets).contains(o);
+    }
+
+    @Override
+    public Iterator<TileSet> iterator() {
+        return Stream.of(this.tileSets).filter(Objects::nonNull).iterator();
+    }
+
+    @Override
+    public Object[] toArray() {
+        return Stream.of(this.tileSets).filter(Objects::nonNull).toArray();
+    }
+
+    @Override
+    public <T> T[] toArray(T[] array) {
+        return Arrays.asList(this.tileSets).toArray(array);
+    }
+
+    @Override
+    public boolean add(TileSet tileSet) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
+    }
+
+    @Override
+    public boolean remove(Object object) {
+        Objects.requireNonNull(object);
+        for (int i = 0; i < this.tileSets.length; i++) {
+            if (object.equals(this.tileSets[i])) {
+                this.tileSets[i] = null;
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public boolean containsAll(Collection<?> collection) {
+        Objects.requireNonNull(collection);
+        List<TileSet> list = Arrays.asList(this.tileSets);
+        return list.containsAll(collection);
+    }
+
+    @Override
+    public boolean addAll(Collection<? extends TileSet> collection) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support addAll");
+    }
+
+    @Override
+    public boolean removeAll(Collection<?> collection) {
+        Objects.requireNonNull(collection);
+        final int count = this.size();
+        collection.forEach(this::remove);
+        return count != this.size();
+    }
+
+    @Override
+    public boolean retainAll(Collection<?> collection) {
+        Objects.requireNonNull(collection);
+        List<?> toRemove = Stream.of(this.tileSets).filter(Objects::nonNull)
+                .filter(set -> !collection.contains(set)).collect(Collectors.toList());
+        return this.removeAll(toRemove);
+    }
+
+    @Override
+    public void clear() {
+        this.memoryTileCache.clear();
+        Arrays.fill(this.tileSets, null);
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java
new file mode 100644
index 0000000000..a96f462b4c
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java
@@ -0,0 +1,63 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+import org.apache.commons.jcs3.engine.behavior.ICache;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+/**
+ * A tile loader for geo images
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class GeoImageTileLoader implements TileLoader, CachedTileLoader {
+
+    protected final ICacheAccess<String, BufferedImageCacheEntry> cache;
+    public TileLoaderListener listener;
+    private static final ThreadPoolExecutor DEFAULT_DOWNLOAD_JOB_DISPATCHER = TMSCachedTileLoader.getNewThreadPoolExecutor("GeoImage-tiler-%d");
+
+    /**
+     * Constructor for the GeoImageTileLoader
+     * @param listener The listener to notify when tile loading finishes
+     * @param cache The cache to use
+     */
+    public GeoImageTileLoader(final TileLoaderListener listener, final ICacheAccess<String, BufferedImageCacheEntry> cache) {
+        CheckParameterUtil.ensureParameterNotNull(cache);
+        this.cache = cache;
+        this.listener = listener;
+    }
+
+    @Override
+    public void clearCache(TileSource source) {
+        this.cache.remove(source.getName() + ICache.NAME_COMPONENT_DELIMITER);
+    }
+
+    @Override
+    public TileJob createTileLoaderJob(Tile tile) {
+        return new GeoImageTileLoaderJob(this.listener, tile,
+                this.cache, DEFAULT_DOWNLOAD_JOB_DISPATCHER
+        );
+    }
+
+    @Override
+    public boolean hasOutstandingTasks() {
+        return DEFAULT_DOWNLOAD_JOB_DISPATCHER.getTaskCount() > DEFAULT_DOWNLOAD_JOB_DISPATCHER.getCompletedTaskCount();
+    }
+
+    @Override
+    public void cancelOutstandingTasks() {
+        for (Runnable runnable : DEFAULT_DOWNLOAD_JOB_DISPATCHER.getQueue()) {
+            DEFAULT_DOWNLOAD_JOB_DISPATCHER.remove(runnable);
+        }
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java
new file mode 100644
index 0000000000..79945abe13
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java
@@ -0,0 +1,110 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.awt.image.RenderedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.concurrent.ThreadPoolExecutor;
+
+import javax.imageio.ImageIO;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.cache.CacheEntry;
+import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
+import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
+import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A job to load geoimage tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class GeoImageTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob,
+        ICachedLoaderListener {
+    private static final TileJobOptions DEFAULT_OPTIONS = new TileJobOptions(0, 0, Collections.emptyMap(),
+            Duration.ofHours(1).getSeconds());
+    private final Tile tile;
+    private final TileLoaderListener listener;
+
+    public GeoImageTileLoaderJob(TileLoaderListener listener, Tile tile,
+            ICacheAccess<String, BufferedImageCacheEntry> cache, ThreadPoolExecutor geoimagetileloader) {
+        super(cache, DEFAULT_OPTIONS, geoimagetileloader);
+        this.listener = listener;
+        this.tile = tile;
+    }
+
+    @Override
+    public void submit() {
+        submit(false);
+    }
+
+    @Override
+    public void submit(boolean force) {
+        try {
+            super.submit(this, force);
+
+        } catch (IllegalArgumentException | IOException e) {
+            Logging.warn(e);
+        }
+    }
+
+    @Override
+    public String getCacheKey() {
+        return tile.getKey();
+    }
+
+    @Override
+    public URL getUrl() throws IOException {
+        if (this.tile.getTileSource() instanceof IImageEntry) {
+            return ((IImageEntry<?>) this.tile.getTileSource()).getFile().toURI().toURL();
+        }
+        return null;
+    }
+
+    @Override
+    public void loadingFinished(CacheEntry data, CacheEntryAttributes attributes, LoadResult result) {
+        try {
+            if (data instanceof BufferedImageCacheEntry) {
+                this.tile.setImage(((BufferedImageCacheEntry) data).getImage());
+            } else if (data != null) {
+                this.tile.loadImage(new ByteArrayInputStream(data.getContent()));
+            }
+            this.tile.finishLoading();
+        } catch (IOException e) {
+            this.tile.setError(e);
+        }
+        this.listener.tileLoadingFinished(this.tile, LoadResult.SUCCESS == result);
+    }
+
+    @Override
+    protected BufferedImageCacheEntry createCacheEntry(byte[] content) {
+        return new BufferedImageCacheEntry(content);
+    }
+
+    @Override
+    protected byte[] loadObjectBytes(File file) throws IOException {
+        if (this.tile.getTileSource() instanceof IImageTiling) {
+            final IImageTiling<?> tileSource = (IImageTiling<?>) this.tile.getTileSource();
+            final RenderedImage image = tileSource.getTileImage(this.tile.getZoom(), this.tile.getXtile(), this.tile.getYtile());
+            if (image == null) {
+                throw new IOException("No image loaded for " + file.toString());
+            }
+            final ByteArrayOutputStream output = new ByteArrayOutputStream(image.getHeight() * image.getWidth());
+            ImageIO.write(image, "jpg", output);
+            return output.toByteArray();
+        }
+        return super.loadObjectBytes(file);
+    }
+}
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..eb43fb09b3
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
@@ -0,0 +1,464 @@
+// 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.Point;
+import java.awt.Rectangle;
+import java.awt.image.RenderedImage;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+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.Tile;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.IProjected;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.cache.JCSCacheManager;
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
+import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
+import org.openstreetmap.josm.spi.preferences.Config;
+
+/**
+ * An interface for tiled images. Primarily used to reduce memory usage in large images.
+ * @author Taylor Smock
+ * @param <I> The image type returned
+ * @since xxx
+ */
+public interface IImageTiling<I extends Image & RenderedImage> extends TileSource {
+
+    /**
+     * 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)));
+    }
+
+    /**
+     * A DeepTileSet (to avoid creating and loading tiles over and over)
+     * @return The deap tile set
+     */
+    DeepTileSet getDeepTileSet();
+
+    /**
+     * 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}.
+     */
+    I 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 I getTileImage(final int zoom, final int column, final int row) {
+        return this.getTileImage(zoom, this.getTileSize(), column, row);
+    }
+
+    /**
+     * 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<Tile> 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);
+        final TileSet tileSet = this.getDeepTileSet().getTileSet(zoom);
+        tileSet.allTilesCreate();
+        return IntStream.range(startX, endX).mapToObj(x -> IntStream.range(startY, endY).mapToObj(y -> new TilePosition(x, y, zoom)))
+                .flatMap(stream -> stream).map(tileSet::getTile).filter(Objects::nonNull);
+    }
+
+    /**
+     * Check if tiling is enabled for this object.
+     *
+     * @return {@code true} if tiling should be u sed
+     */
+    default boolean isTilingEnabled() {
+        return true;
+    }
+
+    /* ************** The following are filler methods ***************** */
+
+    @Override
+    default String getName() {
+        if (this instanceof IImageEntry) {
+            return ((IImageEntry<?>) this).getDisplayName();
+        }
+        return this.getClass().getSimpleName();
+    }
+
+    @Override
+    default String getId() {
+        return this.getClass().getName();
+    }
+
+    @Override
+    default String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default String getTileId(int zoom, int tilex, int tiley) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default double getDistance(double la1, double lo1, double la2, double lo2) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default Point latLonToXY(double lat, double lon, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default Point latLonToXY(ICoordinate point, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default ICoordinate xyToLatLon(Point point, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default ICoordinate xyToLatLon(int x, int y, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default TileXY latLonToTileXY(double lat, double lon, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default TileXY latLonToTileXY(ICoordinate point, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default ICoordinate tileXYToLatLon(Tile tile) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default ICoordinate tileXYToLatLon(int x, int y, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default int getTileXMax(int zoom) {
+        return this.getColumns(zoom);
+    }
+
+    @Override
+    default int getTileXMin(int zoom) {
+        return 0;
+    }
+
+    @Override
+    default int getTileYMax(int zoom) {
+        return this.getRows(zoom);
+    }
+
+    @Override
+    default int getTileYMin(int zoom) {
+        return 0;
+    }
+
+    @Override
+    default boolean isNoTileAtZoom(Map<String, List<String>> headers, int statusCode, byte[] content) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default Map<String, String> getMetadata(Map<String, List<String>> headers) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default IProjected tileXYtoProjected(int x, int y, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default TileXY projectedToTileXY(IProjected p, int zoom) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default boolean isInside(Tile inner, Tile outer) {
+        // If the outer zoom is greater than inner zoom, then it the inner tile cannot be inside the outer tile
+        final int zoomDifference = inner.getZoom() - outer.getZoom();
+        // Example: outer(z13) > inner(z12), so outer covers a smaller "real" area than the inner.
+        if (zoomDifference < 0) {
+            return false;
+        }
+        // Each zoom level has 4x as many tiles, 2x in each direction
+        final double tileScale = Math.pow(2, zoomDifference);
+        final double minX = inner.getXtile() * tileScale;
+        final double minY = inner.getXtile() * tileScale;
+        final double maxX = minX + tileScale - 1;
+        final double maxY = minY + tileScale - 1;
+        return inner.getXtile() >= minX && inner.getXtile() <= maxX
+                && inner.getYtile() >= minY && inner.getYtile() <= maxY;
+    }
+
+    @Override
+    default TileSet getCoveringTileRange(Tile tile, int newZoom) {
+        final int tileZoom = tile.getZoom();
+        final double tileScale = Math.pow(2, newZoom - tileZoom);
+        final DoubleToIntFunction clampDouble = dbl -> Math.toIntExact(Math.round(dbl));
+        if (tileScale < 1) {
+            final TileXY superTile = new TileXY(clampDouble.applyAsInt(tile.getXtile() * tileScale),
+                    clampDouble.applyAsInt(tile.getYtile() * tileScale));
+            return new TileSet(superTile, superTile, newZoom, this.getDeepTileSet().getTileCache(), this);
+        }
+        final TileXY subTile1 = new TileXY(clampDouble.applyAsInt(tile.getXtile() * tileScale),
+                clampDouble.applyAsInt(tile.getYtile() * tileScale));
+        final TileXY subTile2 = new TileXY(subTile1.getXIndex() + clampDouble.applyAsInt(tileScale),
+                subTile1.getY() + clampDouble.applyAsInt(tileScale));
+        return new TileSet(subTile1, subTile2, newZoom, this.getDeepTileSet().getTileCache(), this);
+    }
+
+    @Override
+    default String getServerCRS() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default boolean requiresAttribution() {
+        return false;
+    }
+
+    @Override
+    default String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default String getAttributionLinkURL() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default Image getAttributionImage() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default String getAttributionImageURL() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default String getTermsOfUseText() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+
+    @Override
+    default String getTermsOfUseURL() {
+        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java
new file mode 100644
index 0000000000..4572f73fe6
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java
@@ -0,0 +1,160 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.TileRange;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
+
+/**
+ * A set of tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class TileSet extends TileRange implements Set<Tile> {
+    private final TileCache tileCache;
+    private final TileSource tileSource;
+
+    /**
+     * Constructs a new {@code TileRange}.
+     * @param t1 first tile
+     * @param t2 second tile
+     * @param zoom zoom level
+     */
+    public TileSet(final TileXY t1, final TileXY t2, final int zoom, final TileCache tileCache, final TileSource tileSource) {
+        super(t1, t2, zoom);
+        this.tileCache = tileCache;
+        this.tileSource = tileSource;
+    }
+
+    TileSet() {
+        this.tileCache = null;
+        this.tileSource = null;
+    }
+
+    /**
+     * Gets a stream of all tile positions in this set
+     * @return A stream of all positions
+     */
+    public Stream<TilePosition> tilePositions() {
+        if (zoom == 0) {
+            return Stream.empty(); // Tileset is empty
+        } else {
+            return IntStream.rangeClosed(minX, maxX).mapToObj(
+                    x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
+            ).flatMap(Function.identity());
+        }
+    }
+
+    /**
+     * Get a tile at a position
+     * @param tilePosition The position to get
+     * @return The tile (may be null)
+     */
+    public Tile getTile(final TilePosition tilePosition) {
+        return this.tileCache.getTile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
+    }
+
+    protected List<Tile> allTilesCreate() {
+        return this.allTiles(this::createOrGetTiles).collect(Collectors.toList());
+    }
+
+    private Tile createOrGetTiles(final TilePosition tilePosition) {
+        Tile tile = this.tileCache.getTile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
+        if (tile != null) {
+            return tile;
+        }
+        tile = new Tile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
+        this.tileCache.addTile(tile);
+        return tile;
+    }
+
+    /**
+     * Get all tiles
+     * @return All tiles in this set
+     */
+    public Stream<Tile> allTiles() {
+        return this.allTiles(this::getTile);
+    }
+
+    private Stream<Tile> allTiles(Function<TilePosition, Tile> mapper) {
+        return tilePositions().map(mapper).filter(Objects::nonNull);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return this.allTiles(tile -> this.tileCache.getTile(this.tileSource, tile.getX(), tile.getY(), tile.getZoom()))
+                .anyMatch(Objects::nonNull);
+    }
+
+    @Override
+    public boolean contains(Object o) {
+        if (o instanceof Tile) {
+            Tile tile = (Tile) o;
+            return this.getTile(new TilePosition(tile.getXtile(), tile.getYtile(), tile.getZoom())) != null;
+        }
+        return false;
+    }
+
+    @Override
+    public Iterator<Tile> iterator() {
+        return allTiles().iterator();
+    }
+
+    @Override
+    public Object[] toArray() {
+        return allTiles().toArray();
+    }
+
+    @Override
+    public <T> T[] toArray(T[] array) {
+        return allTiles().collect(Collectors.toList()).toArray(array);
+    }
+
+    @Override
+    public boolean add(Tile tile) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
+    }
+
+    @Override
+    public boolean remove(Object o) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
+    }
+
+    @Override
+    public boolean containsAll(Collection<?> c) {
+        return c.stream().allMatch(this::contains);
+    }
+
+    @Override
+    public boolean addAll(Collection<? extends Tile> c) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
+    }
+
+    @Override
+    public boolean retainAll(Collection<?> c) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
+    }
+
+    @Override
+    public boolean removeAll(Collection<?> c) {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
+    }
+
+    @Override
+    public void clear() {
+        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
+    }
+}
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..a5b8d11d6e
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java
@@ -0,0 +1,148 @@
+// 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 DeepTileSet getDeepTileSet() {
+            return new DeepTileSet(this.getMinZoom(), this.getMaxZoom(), this);
+        }
+
+        @Override
+        public BufferedImage 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");
+        }
+    }
+}
