001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.imagery;
003
004import java.awt.Dimension;
005import java.awt.geom.Point2D;
006import java.awt.image.BufferedImage;
007
008import org.openstreetmap.gui.jmapviewer.Tile;
009import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
010import org.openstreetmap.josm.data.ProjectionBounds;
011import org.openstreetmap.josm.data.coor.EastNorth;
012import org.openstreetmap.josm.data.imagery.CoordinateConversion;
013import org.openstreetmap.josm.data.projection.Projection;
014import org.openstreetmap.josm.data.projection.ProjectionRegistry;
015import org.openstreetmap.josm.data.projection.Projections;
016import org.openstreetmap.josm.gui.MainApplication;
017import org.openstreetmap.josm.spi.preferences.Config;
018import org.openstreetmap.josm.tools.ImageWarp;
019import org.openstreetmap.josm.tools.Utils;
020import org.openstreetmap.josm.tools.bugreport.BugReport;
021
022/**
023 * Tile class that stores a reprojected version of the original tile.
024 * @since 11858
025 */
026public class ReprojectionTile extends Tile {
027
028    protected TileAnchor anchor;
029    private double nativeScale;
030    protected boolean maxZoomReached;
031
032    /**
033     * Constructs a new {@code ReprojectionTile}.
034     * @param source sourec tile
035     * @param xtile X coordinate
036     * @param ytile Y coordinate
037     * @param zoom zoom level
038     */
039    public ReprojectionTile(TileSource source, int xtile, int ytile, int zoom) {
040        super(source, xtile, ytile, zoom);
041    }
042
043    /**
044     * Get the position of the tile inside the image.
045     * @return the position of the tile inside the image
046     * @see #getImage()
047     */
048    public TileAnchor getAnchor() {
049        return anchor;
050    }
051
052    /**
053     * Get the scale that was used for reprojecting the tile.
054     *
055     * This is not necessarily the mapview scale, but may be
056     * adjusted to avoid excessively large cache image.
057     * @return the scale that was used for reprojecting the tile
058     */
059    public double getNativeScale() {
060        return nativeScale;
061    }
062
063    /**
064     * Check if it is necessary to refresh the cache to match the current mapview
065     * scale and get optimized image quality.
066     *
067     * When the maximum zoom is exceeded, this method will generally return false.
068     * @param currentScale the current mapview scale
069     * @return true if the tile should be reprojected again from the source image.
070     */
071    public synchronized boolean needsUpdate(double currentScale) {
072        if (Utils.equalsEpsilon(nativeScale, currentScale))
073            return false;
074        return !maxZoomReached || currentScale >= nativeScale;
075    }
076
077    @Override
078    public void setImage(BufferedImage image) {
079        if (image == null) {
080            reset();
081        } else {
082            transform(image);
083        }
084    }
085
086    /**
087     * Invalidate tile - mark it as not loaded.
088     */
089    public synchronized void invalidate() {
090        this.loaded = false;
091        this.loading = false;
092        this.error = false;
093        this.error_message = null;
094    }
095
096    private synchronized void reset() {
097        this.image = null;
098        this.anchor = null;
099        this.maxZoomReached = false;
100    }
101
102    private EastNorth tileToEastNorth(int x, int y, int z) {
103        return CoordinateConversion.projToEn(source.tileXYtoProjected(x, y, z));
104    }
105
106    /**
107     * Transforms the given image.
108     * @param imageIn tile image to reproject
109     */
110    protected void transform(BufferedImage imageIn) {
111        if (!MainApplication.isDisplayingMapView()) {
112            reset();
113            return;
114        }
115        double scaleMapView = MainApplication.getMap().mapView.getScale();
116        ImageWarp.Interpolation interpolation;
117        switch (Config.getPref().get("imagery.warp.pixel-interpolation", "bilinear")) {
118            case "nearest_neighbor":
119                interpolation = ImageWarp.Interpolation.NEAREST_NEIGHBOR;
120                break;
121            default:
122                interpolation = ImageWarp.Interpolation.BILINEAR;
123        }
124
125        Projection projCurrent = ProjectionRegistry.getProjection();
126        Projection projServer = Projections.getProjectionByCode(source.getServerCRS());
127        EastNorth en00Server = tileToEastNorth(xtile, ytile, zoom);
128        EastNorth en11Server = tileToEastNorth(xtile + 1, ytile + 1, zoom);
129        ProjectionBounds pbServer = new ProjectionBounds(en00Server);
130        pbServer.extend(en11Server);
131        // find east-north rectangle in current projection, that will fully contain the tile
132        ProjectionBounds pbTarget = projCurrent.getEastNorthBoundsBox(pbServer, projServer);
133
134        double margin = 2;
135        Dimension dim = getDimension(pbMarginAndAlign(pbTarget, scaleMapView, margin), scaleMapView);
136        Integer scaleFix = limitScale(source.getTileSize(), Math.sqrt(dim.getWidth() * dim.getHeight()));
137        double scale = scaleFix == null ? scaleMapView : (scaleMapView * scaleFix);
138        ProjectionBounds pbTargetAligned = pbMarginAndAlign(pbTarget, scale, margin);
139
140        ImageWarp.PointTransform pointTransform = pt -> {
141            EastNorth target = new EastNorth(pbTargetAligned.minEast + pt.getX() * scale,
142                    pbTargetAligned.maxNorth - pt.getY() * scale);
143            EastNorth sourceEN = projServer.latlon2eastNorth(projCurrent.eastNorth2latlon(target));
144            double x = source.getTileSize() *
145                    (sourceEN.east() - pbServer.minEast) / (pbServer.maxEast - pbServer.minEast);
146            double y = source.getTileSize() *
147                    (pbServer.maxNorth - sourceEN.north()) / (pbServer.maxNorth - pbServer.minNorth);
148            return new Point2D.Double(x, y);
149        };
150
151        // pixel coordinates of tile origin and opposite tile corner inside the target image
152        // (tile may be deformed / rotated by reprojection)
153        EastNorth en00Current = projCurrent.latlon2eastNorth(projServer.eastNorth2latlon(en00Server));
154        EastNorth en11Current = projCurrent.latlon2eastNorth(projServer.eastNorth2latlon(en11Server));
155        Point2D p00Img = new Point2D.Double(
156                (en00Current.east() - pbTargetAligned.minEast) / scale,
157                (pbTargetAligned.maxNorth - en00Current.north()) / scale);
158        Point2D p11Img = new Point2D.Double(
159                (en11Current.east() - pbTargetAligned.minEast) / scale,
160                (pbTargetAligned.maxNorth - en11Current.north()) / scale);
161
162        ImageWarp.PointTransform transform;
163        int stride = Config.getPref().getInt("imagery.warp.projection-interpolation.stride", 7);
164        if (stride > 0) {
165            transform = new ImageWarp.GridTransform(pointTransform, stride);
166        } else {
167            transform = pointTransform;
168        }
169        Dimension targetDim = getDimension(pbTargetAligned, scale);
170        try {
171            BufferedImage imageOut = ImageWarp.warp(imageIn, targetDim, transform, interpolation);
172            synchronized (this) {
173                this.image = imageOut;
174                this.anchor = new TileAnchor(p00Img, p11Img);
175                this.nativeScale = scale;
176                this.maxZoomReached = scaleFix != null;
177            }
178        } catch (NegativeArraySizeException e) {
179            // See #17387 - https://bugs.openjdk.java.net/browse/JDK-4690476
180            throw BugReport.intercept(e).put("targetDim", targetDim);
181        }
182    }
183
184    // add margin and align to pixel grid
185    private static ProjectionBounds pbMarginAndAlign(ProjectionBounds box, double scale, double margin) {
186        double minEast = Math.floor(box.minEast / scale - margin) * scale;
187        double minNorth = -Math.floor(-(box.minNorth / scale - margin)) * scale;
188        double maxEast = Math.ceil(box.maxEast / scale + margin) * scale;
189        double maxNorth = -Math.ceil(-(box.maxNorth / scale + margin)) * scale;
190        return new ProjectionBounds(minEast, minNorth, maxEast, maxNorth);
191    }
192
193    // dimension in pixel
194    private static Dimension getDimension(ProjectionBounds bounds, double scale) {
195        return new Dimension(
196                (int) Math.round((bounds.maxEast - bounds.minEast) / scale),
197                (int) Math.round((bounds.maxNorth - bounds.minNorth) / scale));
198    }
199
200    /**
201     * Make sure, the image is not scaled up too much.
202     *
203     * This would not give any significant improvement in image quality and may
204     * exceed the user's memory. The correction factor is a power of 2.
205     * @param lenOrig tile size of original image
206     * @param lenNow (averaged) tile size of warped image
207     * @return factor to shrink if limit is exceeded; 1 if it is already at the
208     * limit, but no change needed; null if it is well below the limit and can
209     * still be scaled up by at least a factor of 2.
210     */
211    protected Integer limitScale(double lenOrig, double lenNow) {
212        final double limit = 3;
213        if (lenNow > limit * lenOrig) {
214            int n = (int) Math.ceil((Math.log(lenNow) - Math.log(limit * lenOrig)) / Math.log(2));
215            int f = 1 << n;
216            double lenNowFixed = lenNow / f;
217            if (lenNowFixed > limit * lenOrig) throw new AssertionError();
218            if (lenNowFixed <= limit * lenOrig / 2) throw new AssertionError();
219            return f;
220        }
221        if (lenNow > limit * lenOrig / 2)
222            return 1;
223        return null;
224    }
225}