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}