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