Ticket #21432: 21432.2.patch

File 21432.2.patch, 49.4 KB (added by taylor.smock, 3 years ago)

Fixes pan/zoom -- zoom does not currently zoom to the appropriate location. Panning works, but is somewhat jerky (image tiles are currently loaded in EDT, I'm looking into reusing JCSCachedTileLoader and related classes).

  • src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java

    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 b  
    22package org.openstreetmap.josm.data.cache;
    33
    44import java.awt.image.BufferedImage;
     5import java.awt.image.RenderedImage;
    56import java.io.ByteArrayInputStream;
    67import java.io.ByteArrayOutputStream;
    78import java.io.IOException;
    public class BufferedImageCacheEntry extends CacheEntry {  
    3738     * @return a cache entry for the PNG encoded image
    3839     * @throws UncheckedIOException if an I/O error occurs
    3940     */
    40     public static BufferedImageCacheEntry pngEncoded(BufferedImage img) {
     41    public static BufferedImageCacheEntry pngEncoded(RenderedImage img) {
    4142        try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
    4243            ImageIO.write(img, "png", output);
    4344            return new BufferedImageCacheEntry(output.toByteArray());
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
    index 418fcfd520..54e8248a52 100644
    a b import java.awt.image.BufferedImage;  
    2121import java.io.IOException;
    2222import java.util.Objects;
    2323import java.util.concurrent.Future;
     24import java.util.concurrent.atomic.AtomicInteger;
    2425
    2526import javax.swing.JComponent;
    2627import javax.swing.SwingUtilities;
    import org.openstreetmap.josm.gui.MainApplication;  
    3435import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
    3536import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
    3637import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
     38import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    3739import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
    3840import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
    3941import org.openstreetmap.josm.gui.util.GuiHelper;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    8789
    8890    private final ImgDisplayMouseListener imgMouseListener = new ImgDisplayMouseListener();
    8991
     92    private final AtomicInteger zoom = new AtomicInteger(12);
     93
    9094    private String emptyText;
    9195    private String osdText;
    9296
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    213217        }
    214218
    215219        public void checkRectPos() {
     220            this.checkRectPos(null, 0);
     221        }
     222
     223        /**
     224         * Ensure that the rectangle is within bounds
     225         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
     226         * @param zoom The current zoom level (only used if tiling)
     227         */
     228        public void checkRectPos(final IImageEntry<?> imageEntry, final int zoom) {
     229            final int useWidth;
     230            final int useHeight;
     231            if (imageEntry instanceof IImageTiling) {
     232                useHeight = ((IImageTiling) imageEntry).getHeight(zoom);
     233                useWidth = ((IImageTiling) imageEntry).getWidth(zoom);
     234            } else {
     235                useWidth = init.width;
     236                useHeight = init.height;
     237            }
    216238            if (x < 0) {
    217239                x = 0;
    218240            }
    219241            if (y < 0) {
    220242                y = 0;
    221243            }
    222             if (x + width > init.width) {
    223                 x = init.width - width;
     244            if (width > useWidth) {
     245                width = useWidth;
     246            }
     247            if (height > useHeight) {
     248                height = useHeight;
    224249            }
    225             if (y + height > init.height) {
    226                 y = init.height - height;
     250            if (x + width > useWidth) {
     251                x = useWidth - width;
     252            }
     253            if (y + height > useHeight) {
     254                y = useHeight - height;
    227255            }
    228256        }
    229257
    230258        public void checkRectSize() {
    231             if (width > init.width) {
    232                 width = init.width;
     259            this.checkRectSize(null, 0);
     260        }
     261
     262        /**
     263         * Ensure that the rectangle is the appropriate size
     264         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
     265         * @param zoom The current zoom level (only used if tiling)
     266         */
     267        public void checkRectSize(final IImageEntry<?> imageEntry, final int zoom) {
     268            final int useWidth;
     269            final int useHeight;
     270            if (imageEntry instanceof IImageTiling) {
     271                useWidth = ((IImageTiling) imageEntry).getWidth(zoom);
     272                useHeight = ((IImageTiling) imageEntry).getHeight(zoom);
     273            } else {
     274                useWidth = init.width;
     275                useHeight = init.height;
     276            }
     277            if (width > useWidth) {
     278                width = useWidth;
    233279            }
    234             if (height > init.height) {
    235                 height = init.height;
     280            if (height > useHeight) {
     281                height = useHeight;
    236282            }
    237283        }
    238284
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    289335                    }
    290336                }
    291337
    292                 int width = img.getWidth();
    293                 int height = img.getHeight();
    294                 entry.setWidth(width);
    295                 entry.setHeight(height);
     338                // Only set width/height if the entry is not something that can be tiled
     339                // Tiling *requires* knowledge of the actual width/height of the image.
     340                if (!(entry instanceof IImageTiling)) {
     341                    int width = img.getWidth();
     342                    int height = img.getHeight();
     343                    entry.setWidth(width);
     344                    entry.setHeight(height);
     345                }
    296346
    297347                synchronized (ImageDisplay.this) {
    298348                    if (this.entry != ImageDisplay.this.entry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    304354                    updateProcessedImage();
    305355                    // This will clear the loading info box
    306356                    ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
    307                     visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
     357                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image, this.entry);
    308358
    309359                    selectedRect = null;
    310360                    errorLoading = false;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    358408
    359409            // Calculate the mouse cursor position in image coordinates to center the zoom.
    360410            if (refreshMousePointInImg)
    361                 mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
     411                mousePointInImg = comp2imgCoord(currentEntry, currentVisibleRect, x, y, getSize(), zoom.get());
    362412
    363413            // Apply the zoom to the visible rectangle in image coordinates
     414            final int zoom;
    364415            if (rotation > 0) {
    365416                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
    366417                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
     418                zoom = ImageDisplay.this.zoom.decrementAndGet();
    367419            } else {
    368420                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
    369421                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
     422                zoom = ImageDisplay.this.zoom.incrementAndGet();
    370423            }
    371424
    372425            // Check that the zoom doesn't exceed MAX_ZOOM:1
    373426            ensureMaxZoom(currentVisibleRect);
    374427
    375428            // The size of the visible rectangle is limited by the image size or the viewer implementation.
     429            // It can also be influenced by whether or not the current entry allows tiling. Tiling image implementations
     430            // don't care what the size of the image buffer is. In fact, the image buffer can be the size of the window.
     431            // So the image buffer really only defines the scale.
    376432            if (imageViewer != null) {
    377                 imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
     433                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentEntry, currentVisibleRect);
    378434            } else {
    379                 currentVisibleRect.checkRectSize();
     435                currentVisibleRect.checkRectSize(currentEntry, zoom);
    380436            }
    381437
    382438            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
    383             Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
     439            final Rectangle drawRect;
     440            if (currentEntry instanceof IImageTiling) {
     441                final byte multiplyBy = rotation > 0 ? (byte) -2 : (byte) 2;
     442                drawRect = new VisRect(currentVisibleRect.x * multiplyBy, currentVisibleRect.y * multiplyBy,
     443                        currentVisibleRect.width * multiplyBy, currentVisibleRect.height * multiplyBy);
     444            } else {
     445                drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
     446            }
    384447            currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
    385448            currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
    386449
    387450            // The position is also limited by the image size
    388             currentVisibleRect.checkRectPos();
     451            currentVisibleRect.checkRectPos(currentEntry, zoom);
    389452
    390453            synchronized (ImageDisplay.this) {
    391454                if (ImageDisplay.this.entry == currentEntry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    447510            }
    448511
    449512            // Calculate the translation to set the clicked point the center of the view.
    450             Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     513            Point click = comp2imgCoord(currentEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    451514            Point center = getCenterImgCoord(currentVisibleRect);
    452515
    453516            currentVisibleRect.x += click.x - center.x;
    454517            currentVisibleRect.y += click.y - center.y;
    455518
    456             currentVisibleRect.checkRectPos();
     519            currentVisibleRect.checkRectPos(currentEntry, ImageDisplay.this.zoom.get());
    457520
    458521            synchronized (ImageDisplay.this) {
    459522                if (ImageDisplay.this.entry == currentEntry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    467530         * a picture part) */
    468531        @Override
    469532        public void mousePressed(MouseEvent e) {
    470             Image currentImage;
    471             VisRect currentVisibleRect;
     533            final Image currentImage;
     534            final VisRect currentVisibleRect;
     535            final IImageEntry<?> imageEntry;
    472536
    473537            synchronized (ImageDisplay.this) {
    474538                currentImage = ImageDisplay.this.image;
    475539                currentVisibleRect = ImageDisplay.this.visibleRect;
     540                imageEntry = ImageDisplay.this.entry;
    476541            }
    477542
    478543            if (currentImage == null)
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    481546            selectedRect = null;
    482547
    483548            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
    484                 mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     549                mousePointInImg = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    485550        }
    486551
    487552        @Override
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    503568                return;
    504569
    505570            if (mouseIsDragging(e) && mousePointInImg != null) {
    506                 Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     571                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    507572                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
    508                 currentVisibleRect.checkRectPos();
     573                currentVisibleRect.checkRectPos(imageEntry, ImageDisplay.this.zoom.get());
    509574                synchronized (ImageDisplay.this) {
    510575                    if (ImageDisplay.this.entry == imageEntry) {
    511576                        ImageDisplay.this.visibleRect = currentVisibleRect;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    514579                // We have to update the mousePointInImg for 360 image panning, as otherwise the panning never stops.
    515580                // This does not work well with the perspective viewer at this time (2021-08-26).
    516581                boolean is360panning = entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType();
    517                 if (is360panning) {
     582                if (is360panning || entry instanceof IImageTiling) {
    518583                    this.mousePointInImg = p;
    519584                }
    520585                ImageDisplay.this.repaint();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    525590            }
    526591
    527592            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
    528                 Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     593                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    529594                currentVisibleRect.checkPointInside(p);
    530595                VisRect selectedRectTemp = new VisRect(
    531596                        Math.min(p.x, mousePointInImg.x),
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    533598                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
    534599                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
    535600                        currentVisibleRect);
    536                 selectedRectTemp.checkRectSize();
    537                 selectedRectTemp.checkRectPos();
     601                selectedRectTemp.checkRectSize(imageEntry, zoom.get());
     602                selectedRectTemp.checkRectPos(imageEntry, zoom.get());
    538603                ImageDisplay.this.selectedRect = selectedRectTemp;
    539604                ImageDisplay.this.repaint();
    540605            }
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    574639                    selectedRect.y -= (selectedRect.height - oldHeight) / 2;
    575640                }
    576641
    577                 selectedRect.checkRectSize();
    578                 selectedRect.checkRectPos();
     642                selectedRect.checkRectSize(currentEntry, zoom.get());
     643                selectedRect.checkRectPos(currentEntry, zoom.get());
    579644            }
    580645
    581646            synchronized (ImageDisplay.this) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    719784            Rectangle r = new Rectangle(currentVisibleRect);
    720785            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
    721786
    722             currentImageViewer.paintImage(g, currentImage, target, r);
     787            if (currentEntry instanceof IImageTiling && ((IImageTiling) currentEntry).isTilingEnabled()) {
     788                currentImageViewer.paintTiledImage(g, (IImageTiling) currentEntry, target, r, zoom.get());
     789            } else {
     790                currentImageViewer.paintImage(g, currentImage, target, r);
     791            }
    723792            paintSelectedRect(g, target, currentVisibleRect, size);
    724793            if (currentErrorLoading && currentEntry != null) {
    725794                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    826895     */
    827896    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
    828897        if (selectedRect != null) {
    829             Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
    830             Point bottomRight = img2compCoord(visibleRectTemp,
     898            Point topLeft = img2compCoord(entry, visibleRectTemp, selectedRect.x, selectedRect.y, size, zoom.get());
     899            Point bottomRight = img2compCoord(entry, visibleRectTemp,
    831900                    selectedRect.x + selectedRect.width,
    832                     selectedRect.y + selectedRect.height, size);
     901                    selectedRect.y + selectedRect.height, size, zoom.get());
    833902            g.setColor(new Color(128, 128, 128, 180));
    834903            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
    835904            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    840909        }
    841910    }
    842911
    843     static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
    844         Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     912    /**
     913     * Convert an image coordinate to a component coordinate
     914     * @param imageEntry The image entry -- only used if tiling
     915     * @param visibleRect The visible rectangle
     916     * @param xImg The x position in the component
     917     * @param yImg The y position in the component
     918     * @param compSize The component size
     919     * @param zoom The current zoom level
     920     * @return The point in the image
     921     */
     922    static Point img2compCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xImg, int yImg, Dimension compSize, int zoom) {
     923        final Rectangle drawRect;
     924        if (imageEntry instanceof IImageTiling) {
     925            drawRect = visibleRect;
     926        } else {
     927            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     928        }
    845929        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
    846930                drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
    847931    }
    848932
    849     static Point comp2imgCoord(VisRect visibleRect, int xComp, int yComp, Dimension compSize) {
    850         Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     933    /**
     934     * Convert a component coordinate to an image coordinate
     935     * @param imageEntry The image entry -- only used if tiling
     936     * @param visibleRect The visible rectangle
     937     * @param xComp The x position in the component
     938     * @param yComp The y position in the component
     939     * @param compSize The component size
     940     * @param zoom The current zoom level
     941     * @return The point in the image
     942     */
     943    static Point comp2imgCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xComp, int yComp, Dimension compSize, int zoom) {
     944        final Rectangle drawRect;
     945        if (imageEntry instanceof IImageTiling) {
     946            drawRect = visibleRect;
     947        } else {
     948            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     949        }
    851950        Point p = new Point(
    852951                        ((xComp - drawRect.x) * visibleRect.width),
    853952                        ((yComp - drawRect.y) * visibleRect.height));
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    9441043            Point center = getCenterImgCoord(currentVisibleRect);
    9451044            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
    9461045                    getWidth(), getHeight());
    947             currentVisibleRect.checkRectSize();
    948             currentVisibleRect.checkRectPos();
     1046            currentVisibleRect.checkRectSize(currentEntry, this.zoom.get());
     1047            currentVisibleRect.checkRectPos(currentEntry, this.zoom.get());
    9491048        }
    9501049
    9511050        synchronized (this) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    10171116        } else {
    10181117            rectangle.height = wFact / getSize().width;
    10191118        }
     1119
     1120        final IImageEntry<?> currentEntry;
     1121        synchronized (this) {
     1122            currentEntry = this.entry;
     1123        }
     1124        if (currentEntry instanceof IImageTiling) {
     1125            IImageTiling imageTiling = (IImageTiling) currentEntry;
     1126            if (this.zoom.get() > imageTiling.getMaxZoom()) {
     1127                this.zoom.set(imageTiling.getMaxZoom());
     1128            } else if (this.zoom.get() < imageTiling.getMinZoom()) {
     1129                this.zoom.set(imageTiling.getMinZoom());
     1130            }
     1131        }
    10201132    }
    10211133
    10221134    /**
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    10271139    public void updateVisibleRectangle() {
    10281140        final VisRect currentVisibleRect;
    10291141        final Image mouseImage;
    1030         final IImageViewer iImageViewer;
     1142        final IImageViewer currentImageViewer;
     1143        final IImageEntry<?> imageEntry;
    10311144        synchronized (this) {
    10321145            currentVisibleRect = this.visibleRect;
    10331146            mouseImage = this.image;
    1034             iImageViewer = this.getIImageViewer(this.entry);
     1147            imageEntry = this.entry;
     1148            currentImageViewer = this.getIImageViewer(imageEntry);
    10351149        }
    1036         if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
    1037             final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
     1150        if (mouseImage != null && currentVisibleRect != null && currentImageViewer != null) {
     1151            final Image maxImageSize = currentImageViewer.getMaxImageSize(this, mouseImage);
    10381152            final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
    10391153            maxVisibleRect.setRect(currentVisibleRect);
    10401154            ensureMaxZoom(maxVisibleRect);
    10411155
    1042             maxVisibleRect.checkRectSize();
     1156            maxVisibleRect.checkRectSize(imageEntry, this.zoom.get());
    10431157            synchronized (this) {
    10441158                this.visibleRect = maxVisibleRect;
    10451159            }
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
    index d5a94886b1..082496e1d4 100644
    a b import static org.openstreetmap.josm.tools.I18n.tr;  
    66import java.awt.Dimension;
    77import java.awt.Graphics2D;
    88import java.awt.Image;
     9import java.awt.Rectangle;
    910import java.awt.geom.AffineTransform;
    1011import java.awt.image.BufferedImage;
    1112import java.io.File;
    import java.net.MalformedURLException;  
    1516import java.net.URL;
    1617import java.util.Collections;
    1718import java.util.Objects;
     19
    1820import javax.imageio.IIOParam;
    1921import javax.imageio.ImageReadParam;
    2022import javax.imageio.ImageReader;
    import javax.imageio.ImageReader;  
    2224import org.openstreetmap.josm.data.ImageData;
    2325import org.openstreetmap.josm.data.gpx.GpxImageEntry;
    2426import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
     27import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    2528import org.openstreetmap.josm.tools.ExifReader;
    2629import org.openstreetmap.josm.tools.ImageProvider;
    2730import org.openstreetmap.josm.tools.Logging;
    import org.openstreetmap.josm.tools.Logging;  
    3033 * Stores info about each image, with an optional thumbnail
    3134 * @since 2662
    3235 */
    33 public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry> {
     36public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>, IImageTiling {
    3437
    3538    private Image thumbnail;
    3639    private ImageData dataSet;
    public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>  
    212215        return applyExifRotation(image);
    213216    }
    214217
     218    @Override
     219    public Image getTileImage(int zoom, int tileSize, int column, int row) {
     220        final Rectangle tile = IImageTiling.super.getTileDimension(zoom, column, row, tileSize);
     221        if (column < 0 || row < 0 || zoom > this.getMaxZoom() || tile.getWidth() <= 0 || tile.getHeight() <= 0) {
     222            return null;
     223        }
     224        final URL imageUrl;
     225        final BufferedImage image;
     226        try {
     227            imageUrl = getImageUrl();
     228            Logging.info(tr("Loading {0} at {1}/{2}/{3} with size {4}", imageUrl, zoom, column, row, tileSize));
     229            image = ImageProvider.read(imageUrl, true, false,
     230                    r -> this.withSubsampling(r, tile, zoom));
     231        } catch (IOException e) {
     232            Logging.error(e);
     233            return null;
     234        }
     235
     236        if (image == null) {
     237            Logging.warn("Unable to load {0}", imageUrl);
     238        }
     239        // applyExifRotation not used here since it will not work with tiled images
     240        // Instead, we will have to rotate the column/row, and then apply rotation here.
     241        return image;
     242    }
     243
    215244    protected URL getImageUrl() throws MalformedURLException {
    216245        return getFile().toURI().toURL();
    217246    }
    218247
     248    private ImageReadParam withSubsampling(ImageReader reader, final Rectangle tile, int zoom) {
     249        ImageReadParam param = reader.getDefaultReadParam();
     250        param.setSourceRegion(tile);
     251        int subsampling = (int) Math.floor(Math.max(Math.pow(IImageTiling.super.getScale(zoom), -1), 1));
     252        param.setSourceSubsampling(subsampling, subsampling, 0, 0);
     253        return param;
     254    }
     255
    219256    private ImageReadParam withSubsampling(ImageReader reader, Dimension target) {
    220257        try {
    221258            ImageReadParam param = reader.getDefaultReadParam();
    public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>  
    247284        g.dispose();
    248285        return rotated;
    249286    }
     287
     288    @Override
     289    public boolean isTilingEnabled() {
     290        // Flipped images are going to need more work (the column/rows will need to be translated)
     291        return IImageTiling.super.isTilingEnabled() && !ExifReader.orientationNeedsCorrection(getExifOrientation());
     292    }
    250293}
  • src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
    index 3c1d41e534..7f9a05a42d 100644
    a b import java.awt.event.ComponentListener;  
    1010import java.awt.image.BufferedImage;
    1111import java.util.Set;
    1212
     13import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    1314import org.openstreetmap.josm.data.imagery.street_level.Projections;
    1415import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     16import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    1517import org.openstreetmap.josm.gui.util.imagery.Vector3D;
    1618
    1719/**
    public interface IImageViewer extends ComponentListener {  
    3436     */
    3537    void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect);
    3638
     39
     40    /**
     41     * Paint the image tile
     42     * @param g The graphics to paint on
     43     * @param entry The image entry (specifically, with the tile size)
     44     * @param tile The tile to paint (x, y, z)
     45     * @param image The image to paint
     46     */
     47    default void paintImageTile(Graphics g, Rectangle target, Rectangle visibleRect, IImageTiling entry, IImageTiling.ImageTile tile, Image image) {
     48        final Rectangle toUse = visibleRect;
     49        g.drawImage(image, -toUse.x + entry.getTileSize() * tile.getXIndex(), -toUse.y + entry.getTileSize() * tile.getYIndex(), null);
     50    }
     51
     52    /**
     53     * Paint the image
     54     * @param g The graphics to paint on
     55     * @param imageEntry The image to paint
     56     * @param target The target area
     57     * @param visibleRect The visible rectangle
     58     * @param zoom The zoom level
     59     */
     60    default void paintTiledImage(Graphics g, IImageTiling imageEntry, Rectangle target, Rectangle visibleRect, int zoom) {
     61        imageEntry.getTiles(zoom, visibleRect).forEachOrdered(pair -> this.paintImageTile(g, target, visibleRect, imageEntry, pair.a, pair.b));
     62    }
     63
    3764    /**
    3865     * Get the default visible rectangle for the projection
    3966     * @param component The component the image will be displayed in
    public interface IImageViewer extends ComponentListener {  
    4269     */
    4370    ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image);
    4471
     72    /**
     73     * Get the default visible rectangle for the projection and entry
     74     * @param component The component the image will be displayed in
     75     * @param image The image that will be shown
     76     * @param entry The entry that will be used
     77     * @return The default visible rectangle
     78     */
     79    default ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
     80        return this.getDefaultVisibleRectangle(component, image);
     81    }
     82
    4583    /**
    4684     * Get the current rotation in the image viewer
    4785     * @return The rotation
    public interface IImageViewer extends ComponentListener {  
    77115        }
    78116    }
    79117
     118    /**
     119     * Check and modify the visible rect size to appropriate dimensions
     120     * @param visibleRect the visible rectangle to update
     121     * @param entry the entry to use for checking
     122     * @param image The image to use for checking
     123     */
     124    default void checkAndModifyVisibleRectSize(Image image, IImageEntry<?> entry, ImageDisplay.VisRect visibleRect) {
     125        if (entry instanceof IImageTiling) {
     126            final IImageTiling tiling = (IImageTiling) entry;
     127            if (visibleRect.width > tiling.getWidth()) {
     128                visibleRect.width = tiling.getWidth();
     129            }
     130            if (visibleRect.height > tiling.getHeight()) {
     131                visibleRect.height = tiling.getHeight();
     132            }
     133            if (visibleRect.x + visibleRect.width > tiling.getWidth()) {
     134                visibleRect.x = tiling.getWidth() - visibleRect.width;
     135            }
     136            if (visibleRect.y + visibleRect.height > tiling.getHeight()) {
     137                visibleRect.y = tiling.getHeight() - visibleRect.height;
     138            }
     139            if (visibleRect.x < 0) {
     140                visibleRect.x = 0;
     141            }
     142            if (visibleRect.y < 0) {
     143                visibleRect.y = 0;
     144            }
     145        } else {
     146            this.checkAndModifyVisibleRectSize(image, visibleRect);
     147        }
     148    }
     149
    80150    /**
    81151     * Get the maximum image size that can be displayed
    82152     * @param imageDisplay The image display
  • src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
    index d570c53dde..08d4add18b 100644
    a b import java.awt.image.BufferedImage;  
    1010import java.util.EnumSet;
    1111import java.util.Set;
    1212
     13import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    1314import org.openstreetmap.josm.data.imagery.street_level.Projections;
    1415import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     16import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    1517
    1618/**
    1719 * The default perspective image viewer class.
    public class Perspective extends ComponentAdapter implements IImageViewer {  
    3638    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
    3739        return new ImageDisplay.VisRect(0, 0, image.getWidth(null), image.getHeight(null));
    3840    }
     41
     42    @Override
     43    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
     44        if (entry instanceof IImageTiling) {
     45            return new ImageDisplay.VisRect(0, 0, ((IImageTiling) entry).getWidth(), ((IImageTiling) entry).getHeight());
     46        }
     47        return IImageViewer.super.getDefaultVisibleRectangle(component, image, entry);
     48    }
    3949}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
    new file mode 100644
    index 0000000000..84ab763822
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.awt.Dimension;
     5import java.awt.Image;
     6import java.awt.Rectangle;
     7import java.awt.image.RenderedImage;
     8import java.io.IOException;
     9import java.text.MessageFormat;
     10import java.util.function.DoubleToIntFunction;
     11import java.util.function.IntUnaryOperator;
     12import java.util.stream.IntStream;
     13import java.util.stream.Stream;
     14
     15import org.apache.commons.jcs3.access.CacheAccess;
     16import org.openstreetmap.gui.jmapviewer.TileXY;
     17import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
     18import org.openstreetmap.josm.data.cache.JCSCacheManager;
     19import org.openstreetmap.josm.spi.preferences.Config;
     20import org.openstreetmap.josm.tools.Logging;
     21import org.openstreetmap.josm.tools.Pair;
     22
     23/**
     24 * An interface for tiled images. Primarily used to reduce memory usage in large images.
     25 * @author Taylor Smock
     26 * @since xxx
     27 */
     28public interface IImageTiling {
     29    /**
     30     * Just a class to hold tile information
     31     */
     32    class ImageTile extends TileXY {
     33        final int zoom;
     34        /**
     35         * Returns an instance of an image tile.
     36         * @param x number of the tile
     37         * @param y number of the tile
     38         * @param z The zoom level
     39         */
     40        public ImageTile(int x, int y, int z) {
     41            super(x, y);
     42            this.zoom = z;
     43        }
     44    }
     45
     46    /**
     47     * The default tile size for the image tiles -- each tile takes 1024 px * 1024 px * 4 bytes = 4 MiB max
     48     * A 4k image (4160x3120) has (Math.ceil(4160/1024) * Math.ceil(3120/1024) = 20 tiles). Some tiles are almost empty.
     49     * This gives a reasonable number of tiles for most image sizes.
     50     */
     51    int DEFAULT_TILE_SIZE = 1024;
     52
     53    /** A good default minimum zoom (the image size is {@link #DEFAULT_TILE_SIZE} max, at 1024 it is 5) */
     54    int DEFAULT_MIN_ZOOM = (int) Math.round(Math.log(Math.sqrt(DEFAULT_TILE_SIZE))/Math.log(2));
     55
     56    /** A cache for images */
     57    CacheAccess<String, BufferedImageCacheEntry> IMAGE_CACHE = JCSCacheManager.getCache("iimagetiling", 100, 1_000, Config.getDirs().getCacheDirectory(true).getAbsolutePath());
     58
     59    /**
     60     * Get the size of the image at a specified zoom level
     61     * @param zoom The zoom level. Zoom 0 == 1 px for the image. Zoom 1 == 4 px for the image.
     62     * @return The number of pixels (max, for a square image)
     63     */
     64    static long getSizeAtZoom(final int zoom) {
     65        final long dimension = 1L << zoom;
     66        return dimension * dimension;
     67    }
     68
     69    /**
     70     * Get the default tile size.
     71     * @return The tile size to use
     72     */
     73    default int getDefaultTileSize() {
     74        return DEFAULT_TILE_SIZE;
     75    }
     76
     77    /**
     78     * Get the tile size.
     79     * @return The tile size to use
     80     */
     81    default int getTileSize() {
     82        return this.getDefaultTileSize();
     83    }
     84
     85    /**
     86     * Get the maximum zoom that the image supports
     87     * Feel free to override and cache the result for performance reasons.
     88     *
     89     * @return The maximum zoom of the image
     90     */
     91    default int getMaxZoom() {
     92        final int maxSize = Math.max(this.getWidth(), this.getHeight());
     93        return (int) Math.round(Math.ceil(Math.log(maxSize) / Math.log(2)));
     94    }
     95
     96    /**
     97     * Get the minimum zoom that the image supports or makes sense
     98     * @return The minimum zoom that makes sense
     99     */
     100    default int getMinZoom() {
     101        final IntUnaryOperator minZoom = input -> Math.toIntExact(Math.round(Math.floor(this.getMaxZoom() + Math.log((double) this.getTileSize() / input) / Math.log(2))));
     102        return Math.min(minZoom.applyAsInt(this.getWidth()), minZoom.applyAsInt(this.getHeight()));
     103    }
     104
     105    /**
     106     * Get the current scale of the image
     107     * @param zoom The zoom level
     108     * @return The scaling of the image at the specified level
     109     */
     110    default double getScale(final int zoom) {
     111        return Math.pow(2, (double) zoom - this.getMaxZoom());
     112    }
     113
     114    /**
     115     * Get the width of the image
     116     * @return The width of the image
     117     */
     118    int getWidth();
     119
     120    /**
     121     * Get the width of the image at a specified scale
     122     * @param zoom The zoom to use
     123     * @return The width at the specified scale
     124     */
     125    default int getWidth(final int zoom) {
     126        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getWidth()));
     127    }
     128
     129    /**
     130     * Get the height of the image
     131     * @return The height of the image
     132     */
     133    int getHeight();
     134
     135    /**
     136     * Get the height of the image at a specified scale
     137     * @param zoom The zoom to use
     138     * @return The height at the specified scale
     139     */
     140    default int getHeight(final int zoom) {
     141        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getHeight()));
     142    }
     143
     144    /**
     145     * Get the size of the image
     146     * @return The image size at the zoom level
     147     */
     148    default Dimension getSize(int zoom) {
     149        return new Dimension(this.getWidth(zoom), this.getHeight(zoom));
     150    }
     151
     152    /**
     153     * Get the number of rows at a specified zoom level
     154     * @param zoom The zoom level
     155     * @return The number of rows
     156     */
     157    default int getRows(final int zoom) {
     158        return this.getRows(zoom, this.getTileSize());
     159    }
     160
     161    /**
     162     * Get the number of rows at a specified zoom level
     163     * @param zoom The zoom level
     164     * @param tileSize The tile size
     165     * @return The number of rows
     166     */
     167    default int getRows(final int zoom, final int tileSize) {
     168        final int height = this.getHeight(zoom);
     169        return Math.toIntExact(Math.round(Math.ceil(height / (double) tileSize)));
     170    }
     171
     172    /**
     173     * Get the number of columns at a specified zoom level
     174     * @param zoom The zoom level
     175     * @return The number of columns
     176     */
     177    default int getColumns(final int zoom) {
     178        return this.getColumns(zoom, this.getTileSize());
     179    }
     180
     181    /**
     182     * Get the number of columns at a specified zoom level
     183     * @param zoom The zoom level
     184     * @param tileSize The tile size
     185     * @return The number of columns
     186     */
     187    default int getColumns(final int zoom, final int tileSize) {
     188        final int width = this.getWidth(zoom);
     189        return Math.toIntExact(Math.round(Math.ceil(width / (double) tileSize)));
     190    }
     191
     192    /**
     193     * Get the image to show for a specific tile location. This should be cached by the implementation in most cases.
     194     * Top-left corner is 0,0
     195     * @param zoom The zoom to use
     196     * @param tileSize The tile size to use
     197     * @param column The column to get (x)
     198     * @param row The row to get (y)
     199     * @return The image to display (not padded). May be {@code null}.
     200     */
     201    Image getTileImage(int zoom, int tileSize, int column, int row);
     202
     203    /**
     204     * Get the image to show for a specific tile location with the default tile size
     205     * Top-left corner is 0,0
     206     * @param zoom The zoom to use
     207     * @param column The column to get (x)
     208     * @param row The row to get (y)
     209     * @return The image to display (not padded). May be {@code null}.
     210     */
     211    default Image getTileImage(final int zoom, final int column, final int row) {
     212        final String storage = MessageFormat.format("{0}: {1}/{2}/{3}", this, zoom, column, row);
     213        BufferedImageCacheEntry image = IMAGE_CACHE.get(storage);
     214        if (image == null) {
     215            Image newImage = this.getTileImage(zoom, this.getTileSize(), column, row);
     216            if (newImage instanceof RenderedImage) {
     217                IMAGE_CACHE.put(storage, BufferedImageCacheEntry.pngEncoded((RenderedImage) newImage));
     218            }
     219            return newImage;
     220        }
     221        try {
     222            return image.getImage();
     223        } catch (IOException e) {
     224            Logging.error(e);
     225        }
     226        return null;
     227    }
     228
     229    /**
     230     * Get the subsection of the image to show
     231     * Top-left corner is 0,0
     232     * @param zoom The zoom to use
     233     * @param column The column to get (x)
     234     * @param row The row to get (y)
     235     * @return The subsection of the image to get
     236     */
     237    default Rectangle getTileDimension(final int zoom, final int column, final int row) {
     238        return this.getTileDimension(zoom, column, row, this.getTileSize());
     239    }
     240
     241    /**
     242     * Get the subsection of the image to show
     243     * Top-left corner is 0,0
     244     * @param zoom The zoom to use
     245     * @param column The column to get (x)
     246     * @param row The row to get (y)
     247     * @param tileSize the tile size to use
     248     * @return The subsection of the image to get
     249     */
     250    default Rectangle getTileDimension(final int zoom, final int column, final int row, final int tileSize) {
     251        final double scale = this.getScale(zoom); // e.g., 1, 1/2, 1/4, etc.
     252        final DoubleToIntFunction roundToInt = dbl -> Math.toIntExact(Math.round(Math.floor(dbl)));
     253        final int x = roundToInt.applyAsInt(column * tileSize / scale);
     254        final int y = roundToInt.applyAsInt(row * tileSize / scale);
     255        final int defaultDimension = roundToInt.applyAsInt(tileSize / scale);
     256        final int width = Math.min(defaultDimension, roundToInt.applyAsInt(this.getWidth() - column * tileSize / scale));
     257        final int height = Math.min(defaultDimension, roundToInt.applyAsInt(this.getHeight() - row * tileSize / scale));
     258        return new Rectangle(x, y, width, height);
     259    }
     260
     261    /**
     262     * Get the tiles for a zoom level given a visible rectangle
     263     * @param zoom The zoom to get
     264     * @param visibleRect The rectangle to get
     265     * @return A stream of tiles to images (may be parallel)
     266     */
     267    default Stream<Pair<ImageTile, Image>> getTiles(int zoom, Rectangle visibleRect) {
     268        // We very specifically want to "overscan" -- this fixes some issues where the image isn't fully loaded
     269        final int startX = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinX() / this.getTileSize()))) - 1);
     270        final int startY = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinY() / this.getTileSize()))) - 1);
     271        final int endX = Math.min(this.getColumns(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxX() / this.getTileSize()))) + 1);
     272        final int endY = Math.min(this.getRows(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxY() / this.getTileSize()))) + 1);
     273        return IntStream.rangeClosed(startX, endX).mapToObj(x -> IntStream.rangeClosed(startY, endY).mapToObj(y -> new ImageTile(x, y, zoom)))
     274                .flatMap(stream -> stream).parallel().map(tile -> new Pair<>(tile, this.getTileImage(tile.zoom, tile.getXIndex(), tile.getYIndex())));
     275    }
     276
     277    /**
     278     * Check if tiling is enabled for this object.
     279     *
     280     * @return {@code true} if tiling should be u sed
     281     */
     282    default boolean isTilingEnabled() {
     283        return true;
     284    }
     285}
  • new file test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java

    diff --git a/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java b/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java
    new file mode 100644
    index 0000000000..0ad1096efc
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import static org.junit.jupiter.api.Assertions.assertAll;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.assertNotEquals;
     7import static org.junit.jupiter.api.Assertions.assertNotNull;
     8import static org.junit.jupiter.api.Assertions.assertTrue;
     9
     10import java.awt.Image;
     11import java.awt.image.BufferedImage;
     12import java.util.concurrent.atomic.AtomicInteger;
     13import java.util.stream.Stream;
     14
     15import org.junit.jupiter.params.ParameterizedTest;
     16import org.junit.jupiter.params.provider.Arguments;
     17import org.junit.jupiter.params.provider.MethodSource;
     18import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     19
     20/**
     21 * Test class for {@link IImageTiling}
     22 * @author Taylor Smock
     23 */
     24@BasicPreferences
     25class IImageTilingTest {
     26    static Stream<Arguments> testSizeAtZoom() {
     27        return Stream.of(Arguments.of(0, 1L), Arguments.of(1, 4L));
     28    }
     29
     30    @ParameterizedTest
     31    @MethodSource
     32    void testSizeAtZoom(int zoom, long expected) {
     33        assertEquals(expected, IImageTiling.getSizeAtZoom(zoom));
     34    }
     35
     36    static Stream<Arguments> getImageTilingSamples() {
     37        return Stream.of(
     38                Arguments.of(new ImageTiling(new BufferedImage(5000, 2500, BufferedImage.TYPE_INT_ARGB)), 13)
     39        );
     40    }
     41
     42    @ParameterizedTest
     43    @MethodSource("getImageTilingSamples")
     44    void testGetTileSizes(final ImageTiling imageTiling) {
     45        // The fake class uses default methods
     46        assertEquals(imageTiling.getTileSize(), imageTiling.getDefaultTileSize());
     47    }
     48
     49    @ParameterizedTest
     50    @MethodSource("getImageTilingSamples")
     51    void testGetMaxZoom(final ImageTiling imageTiling, final int maxZoom) {
     52        assertEquals(maxZoom, imageTiling.getMaxZoom());
     53    }
     54
     55    @ParameterizedTest
     56    @MethodSource("getImageTilingSamples")
     57    void testGetScale(final ImageTiling imageTiling, final int maxZoom) {
     58        assertEquals(1, imageTiling.getScale(maxZoom));
     59        assertEquals(0.5, imageTiling.getScale(maxZoom - 1));
     60        assertEquals(0.25, imageTiling.getScale(maxZoom - 2));
     61    }
     62
     63    @ParameterizedTest
     64    @MethodSource("getImageTilingSamples")
     65    void testGetWidth(final IImageTiling imageTiling, final int maxZoom) {
     66        assertEquals(imageTiling.getWidth(), imageTiling.getWidth(maxZoom));
     67        assertEquals(imageTiling.getWidth() / 2, imageTiling.getWidth(maxZoom - 1));
     68        assertEquals(imageTiling.getWidth() / 4, imageTiling.getWidth(maxZoom - 2));
     69    }
     70
     71    @ParameterizedTest
     72    @MethodSource("getImageTilingSamples")
     73    void testGetHeight(final IImageTiling imageTiling, final int maxZoom) {
     74        assertEquals(imageTiling.getHeight(), imageTiling.getHeight(maxZoom));
     75        assertEquals(imageTiling.getHeight() / 2, imageTiling.getHeight(maxZoom - 1));
     76        assertEquals(imageTiling.getHeight() / 4, imageTiling.getHeight(maxZoom - 2));
     77    }
     78
     79    @ParameterizedTest
     80    @MethodSource("getImageTilingSamples")
     81    void testGetRows(final IImageTiling imageTiling, final int maxZoom) {
     82        assertEquals(3, imageTiling.getRows(maxZoom));
     83        assertEquals(2, imageTiling.getRows(maxZoom - 1));
     84    }
     85
     86    @ParameterizedTest
     87    @MethodSource("getImageTilingSamples")
     88    void testGetColumns(final IImageTiling imageTiling, final int maxZoom) {
     89        assertEquals(5, imageTiling.getColumns(maxZoom));
     90        assertEquals(3, imageTiling.getColumns(maxZoom - 1));
     91    }
     92
     93    @ParameterizedTest
     94    @MethodSource("getImageTilingSamples")
     95    void testGetTileImage(final IImageTiling imageTiling, final int maxZoom) {
     96        assertNotNull(imageTiling.getTileImage(maxZoom, 0, 0));
     97        final Image cornerImage = imageTiling.getTileImage(maxZoom, imageTiling.getColumns(maxZoom) - 1, imageTiling.getRows(maxZoom) - 1);
     98        assertAll(() -> assertNotEquals(-1, cornerImage.getWidth(null)),
     99                () -> assertNotEquals(-1, cornerImage.getHeight(null)),
     100                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getWidth(null)),
     101                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getHeight(null)));
     102    }
     103
     104    @ParameterizedTest
     105    @MethodSource("getImageTilingSamples")
     106    void testGetTileDimension(final IImageTiling imageTiling) {
     107        imageTiling.getTileDimension(0, 0, 0);
     108    }
     109
     110    private static class ImageTiling implements IImageTiling {
     111        private final int width;
     112        private final int height;
     113        private final Image image;
     114        final AtomicInteger counter = new AtomicInteger(0);
     115        ImageTiling(final Image image) {
     116            this.image = image;
     117            this.width = image.getWidth(null);
     118            this.height = image.getHeight(null);
     119        }
     120
     121        @Override
     122        public int getWidth() {
     123            return this.width;
     124        }
     125
     126        @Override
     127        public int getHeight() {
     128            return this.height;
     129        }
     130
     131        @Override
     132        public Image getTileImage(int zoom, int tileSize, int column, int row) {
     133            this.counter.incrementAndGet();
     134            if (image instanceof BufferedImage) {
     135                final BufferedImage bufferedImage = (BufferedImage) image;
     136                return bufferedImage.getSubimage(column * tileSize, row * tileSize,
     137                        Math.min(tileSize, bufferedImage.getWidth() - column * tileSize - 1),
     138                        Math.min(tileSize, bufferedImage.getHeight() - row * tileSize - 1));
     139            }
     140            throw new UnsupportedOperationException("The test ImageTiling class only supports BufferedImages");
     141        }
     142    }
     143}