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