Skip to content

Commit

Permalink
See #11487: Have josm render data to tiles
Browse files Browse the repository at this point in the history
Start adding basic rendering tests for tiled rendering.
Right now, the test only looks at a point in the center of the tile; there seems
to be some positioning/stretching issues at the edges that I need to debug and
fix.

git-svn-id: https://josm.openstreetmap.de/svn/trunk@19220 0c6e7542-c601-0410-84e7-c038aed88b3b
  • Loading branch information
taylor.smock committed Sep 11, 2024
1 parent 7e5b840 commit 3763d44
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.Image;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.Transparency;
import java.awt.event.MouseEvent;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -21,9 +21,9 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.function.Consumer;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -51,6 +51,7 @@ public final class StyledTiledMapRenderer extends StyledMapRenderer {
private CacheAccess<TileZXY, ImageCache> cache;
private int zoom;
private Consumer<TileZXY> notifier;
private final ExecutorService worker;

/**
* Constructs a new {@code StyledMapRenderer}.
Expand All @@ -64,6 +65,7 @@ public final class StyledTiledMapRenderer extends StyledMapRenderer {
*/
public StyledTiledMapRenderer(Graphics2D g, NavigatableComponent nc, boolean isInactiveMode) {
super(g, nc, isInactiveMode);
this.worker = MainApplication.worker;
}

@Override
Expand All @@ -73,11 +75,11 @@ public void render(OsmData<?, ?, ?, ?> data, boolean renderVirtualNodes, Bounds
super.render(data, renderVirtualNodes, bounds);
return;
}
final Executor worker = MainApplication.worker;
final Executor worker = this.worker;
final BufferedImage tempImage;
final Graphics2D tempG2d;
// I'd like to avoid two image copies, but there are some issues using the original g2d object
tempImage = nc.getGraphicsConfiguration().createCompatibleImage(this.nc.getWidth(), this.nc.getHeight(), Transparency.TRANSLUCENT);
tempImage = createCompatibleImage(nc, this.nc.getWidth(), this.nc.getHeight());
tempG2d = tempImage.createGraphics();
tempG2d.setComposite(AlphaComposite.DstAtop); // Avoid tile lines in large areas

Expand All @@ -93,7 +95,7 @@ public void render(OsmData<?, ?, ?, ?> data, boolean renderVirtualNodes, Bounds
final Bounds box2 = TileZXY.tileToBounds(tile);
final Point min = this.nc.getPoint(box2.getMin());
final Point max = this.nc.getPoint(box2.getMax());
tileSize = max.x - min.x + BUFFER_PIXELS;
tileSize = max.x - min.x;
}

// Sort the tiles based off of proximity to the mouse pointer
Expand Down Expand Up @@ -150,8 +152,9 @@ public void render(OsmData<?, ?, ?, ?> data, boolean renderVirtualNodes, Bounds
} else {
painted++;
}
// There seems to be an off-by-one error somewhere.
tempG2d.drawImage(tileImage, point.x + 1, point.y + 1, null, null);
// There seems to be an off-by-one error somewhere. Seems to be tied to sign of lat/lon
final int offset = (tile.lat() > 0 ? 1 : 0) + (tile.lon() >= 0 ? 1 : 0);
tempG2d.drawImage(tileImage, point.x + 1, point.y + offset, null, null);
} else {
Logging.trace("StyledMapRenderer did not paint tile {1}", tile);
}
Expand Down Expand Up @@ -249,19 +252,26 @@ public int getHeight() {
final Bounds bounds = generateRenderArea(tiles);

temporaryView.zoomTo(bounds.getCenter().getEastNorth(ProjectionRegistry.getProjection()), mapState.getScale());
BufferedImage bufferedImage = Optional.ofNullable(nc.getGraphicsConfiguration())
.map(gc -> gc.createCompatibleImage(tileSize * xCount + xCount, tileSize * yCount + yCount, Transparency.TRANSLUCENT))
.orElseGet(() -> new BufferedImage(tileSize * xCount + xCount, tileSize * yCount + yCount, BufferedImage.TYPE_INT_ARGB));
BufferedImage bufferedImage = createCompatibleImage(nc, width, height);
Graphics2D g2d = bufferedImage.createGraphics();
try {
g2d.setRenderingHints(Map.of(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON));
g2d.setTransform(AffineTransform.getTranslateInstance(-BUFFER_TILES * (double) tileSize, -BUFFER_TILES * (double) tileSize));
final AbstractMapRenderer tilePainter = MapRendererFactory.getInstance().createActiveRenderer(g2d, temporaryView, false);
tilePainter.render(data, true, bounds);
} finally {
g2d.dispose();
}
return bufferedImage;
final int bufferPixels = BUFFER_TILES * tileSize;
return bufferedImage.getSubimage(bufferPixels, bufferPixels,
width - 2 * bufferPixels + BUFFER_PIXELS, height - 2 * bufferPixels + BUFFER_PIXELS);
}

private static BufferedImage createCompatibleImage(NavigatableComponent nc, int width, int height) {
final GraphicsConfiguration gc = nc.getGraphicsConfiguration();
if (gc == null) {
return new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
}
return gc.createCompatibleImage(width, height, Transparency.TRANSLUCENT);
}

/**
Expand Down Expand Up @@ -318,11 +328,11 @@ public void run() {
final int minY = tileCollection.stream().map(t -> t.tile).mapToInt(TileZXY::y).min().orElse(this.tile.y());
for (TileLoader loader : tileCollection) {
final TileZXY txy = loader.tile;
final int x = (txy.x() - minX) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2;
final int y = (txy.y() - minY) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2;
final int wh = tileSize - BUFFER_PIXELS / 2;
final int x = (txy.x() - minX) * tileSize;
final int y = (txy.y() - minY) * tileSize;
final int wh = tileSize;

final BufferedImage tileImage = tImage.getSubimage(x, y, wh, wh);
final BufferedImage tileImage = tImage.getSubimage(x, y, wh + BUFFER_PIXELS, wh + BUFFER_PIXELS);
loader.cacheTile(tileImage);
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import org.openstreetmap.josm.data.coor.ILatLon;

/**
* A record used for storing tile information for painting
* A record used for storing tile information for painting.
* The origin is upper-left, not lower-left (so more like Google tile coordinates than TMS tile coordinates).
* @since 19176
*/
public final class TileZXY implements ILatLon {
Expand Down Expand Up @@ -143,9 +144,10 @@ public static double yToLat(int y, int zoom) {
* @return The specified tile coordinates at the specified zoom
*/
public static TileZXY latLonToTile(double lat, double lon, int zoom) {
int xCoord = (int) Math.floor(Math.pow(2, zoom) * (180 + lon) / 360);
int yCoord = (int) Math.floor(Math.pow(2, zoom) *
(1 - Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2);
final double zoom2 = Math.pow(2, zoom);
final double latLog = Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat)));
final int xCoord = (int) Math.floor(zoom2 * (180 + lon) / 360);
final int yCoord = (int) Math.floor(zoom2 * (1 - latLog / Math.PI) / 2);
return new TileZXY(zoom, xCoord, yCoord);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,20 @@ public static void assertImageEquals(
return;
}
final BufferedImage reference = ImageIO.read(referenceImageFile);
assertImageEquals(testIdentifier, reference, image, thresholdPixels, thresholdTotalColorDiff, diffImageConsumer);
}

/**
* Compares the reference image file with the actual images given as {@link BufferedImage}.
* @param testIdentifier a test identifier for error messages
* @param reference the reference image
* @param image the actual image
* @param thresholdPixels maximum number of differing pixels
* @param thresholdTotalColorDiff maximum sum of color value differences
* @param diffImageConsumer a consumer for a rendered image highlighting the differing pixels, may be null
*/
public static void assertImageEquals(String testIdentifier, BufferedImage reference, BufferedImage image,
int thresholdPixels, int thresholdTotalColorDiff, Consumer<BufferedImage> diffImageConsumer) {
assertEquals(reference.getWidth(), image.getWidth());
assertEquals(reference.getHeight(), image.getHeight());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.data.osm.visitor.paint;

import static org.openstreetmap.josm.gui.mappaint.MapCSSRendererTest.assertImageEquals;

import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import javax.imageio.ImageIO;

import org.apache.commons.jcs3.access.CacheAccess;
import org.awaitility.Awaitility;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.openstreetmap.josm.TestUtils;
import org.openstreetmap.josm.data.Bounds;
import org.openstreetmap.josm.data.cache.JCSCacheManager;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.osm.DataSet;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.data.osm.OsmPrimitive;
import org.openstreetmap.josm.gui.MainApplication;
import org.openstreetmap.josm.gui.NavigatableComponent;
import org.openstreetmap.josm.testutils.annotations.Main;
import org.openstreetmap.josm.testutils.annotations.Projection;

/**
* Test class for {@link StyledTiledMapRenderer}
*/
@Main
@Projection
class StyledTiledMapRendererTest {
static Stream<Arguments> testRender() {
final Function<TileZXY, OsmPrimitive[]> generateNodes = tile -> {
final Bounds bounds = TileZXY.tileToBounds(tile);
return new OsmPrimitive[] {
new Node(bounds.getCenter()),
new Node(bounds.getMin()),
new Node(bounds.getMax()),
new Node(new LatLon(bounds.getMinLat(), bounds.getMaxLon())),
new Node(new LatLon(bounds.getMaxLat(), bounds.getMinLon())),
};
};
// Everything but the 5 point nodes is just to make it easier to figure out why it is failing
final Function<TileZXY, Stream<Arguments>> generateTests = tile -> Stream.of(
Arguments.of("Center node", (Supplier<DataSet>) () -> new DataSet(generateNodes.apply(tile)[0]), tile)
);
return Stream.concat(
// Tiles around 0, 0
IntStream.rangeClosed(2097151, 2097152).mapToObj(x -> IntStream.rangeClosed(2097151, 2097152)
.mapToObj(y -> new TileZXY(22, x, y))).flatMap(Function.identity()),
// Tiles in the four quadrants far away from 0, 0
Stream.of(new TileZXY(16, 13005, 25030),
new TileZXY(14, 5559, 10949),
new TileZXY(15, 31687, 21229),
new TileZXY(13, 8135, 2145)))
.flatMap(generateTests);
}

@ParameterizedTest(name = "{0} - {2}")
@MethodSource
void testRender(String testIdentifier, final Supplier<DataSet> dataSetSupplier, final TileZXY tile)
throws InterruptedException, ExecutionException {
final int zoom = tile.zoom();
final Bounds viewArea = TileZXY.tileToBounds(tile);
final CacheAccess<TileZXY, ImageCache> cache = JCSCacheManager.getCache("StyledTiledMapRendererTest:testRender");
cache.clear();
final DataSet ds = dataSetSupplier.get();
final NavigatableComponent nc = new NavigatableComponent() {
@Override
public int getWidth() {
return 800;
}

@Override
public int getHeight() {
return 600;
}
};
nc.zoomTo(viewArea);
final ExecutorService worker = MainApplication.worker;
final BufferedImage oldRenderStyle = render((g2d) -> new StyledMapRenderer(g2d, nc, false), ds, nc);
final Function<Graphics2D, StyledTiledMapRenderer> newRenderer = (g2d) -> {
StyledTiledMapRenderer stmr = new StyledTiledMapRenderer(g2d, nc, false);
stmr.setCache(viewArea, cache, zoom, ignored -> { /* ignored */ });
return stmr;
};
// First "renders" schedules off-thread rendering. We need to loop here since a render call may only schedule a small amount of tiles.
int size = -1;
while (size != cache.getMatching(".*").size()) {
size = cache.getMatching(".*").size();
render(newRenderer, ds, nc);
Awaitility.await().until(() -> cache.getMatching(".*").values().stream().allMatch(i -> i.imageFuture() == null));
}
worker.submit(() -> { /* Sync */ }).get();
// Second render actually does the painting
final BufferedImage newRenderStyle = render(newRenderer, ds, nc);
worker.submit(() -> { /* Sync */ }).get();
var entries = cache.getMatching(".*").entrySet().stream().filter(e -> {
BufferedImage image = (BufferedImage) e.getValue().image();
return Arrays.stream(image.getRGB(0, 0, image.getWidth(), image.getHeight(),
null, 0, image.getWidth())).anyMatch(i -> i != 0);
}).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().image()));
try {
assertImageEquals(testIdentifier, oldRenderStyle, newRenderStyle, 0, 0, diff -> {
try {
if (!Files.isDirectory(Paths.get(TestUtils.getTestDataRoot(), "output"))) {
Files.createDirectories(Paths.get(TestUtils.getTestDataRoot(), "output"));
}
final String basename = TestUtils.getTestDataRoot() + "output/" +
testIdentifier + ' ' + tile.zoom() + '-' + tile.x() + '-' + tile.y();
ImageIO.write(diff, "png", new File(basename + "-diff.png"));
ImageIO.write(newRenderStyle, "png", new File(basename + "-new.png"));
ImageIO.write(oldRenderStyle, "png", new File(basename + "-old.png"));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} finally {
cache.clear();
}
}

private static BufferedImage render(Function<Graphics2D, ? extends StyledMapRenderer> renderer,
final DataSet ds, final NavigatableComponent nc) {
final BufferedImage bufferedImage = new BufferedImage(nc.getWidth(), nc.getHeight(), BufferedImage.TYPE_INT_ARGB);
final Graphics2D g2d = bufferedImage.createGraphics();
final StyledMapRenderer styledMapRenderer = renderer.apply(g2d);
styledMapRenderer.render(ds, true, nc.getRealBounds());
g2d.dispose();
return bufferedImage;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.data.osm.visitor.paint;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.stream.Stream;

import nl.jqno.equalsverifier.EqualsVerifier;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.openstreetmap.josm.data.Bounds;

class TileZXYTest {
static Stream<Arguments> testBBoxCalculation() {
return Stream.of(
Arguments.of(new Bounds(-85.0511288, -180, 85.0511288, 180), new TileZXY(0, 0, 0)),
Arguments.of(new Bounds(0, -180, 85.0511288, 0), new TileZXY(1, 0, 0)),
Arguments.of(new Bounds(0, 0, 85.0511288, 180), new TileZXY(1, 1, 0)),
Arguments.of(new Bounds(-85.0511288, -180, 0, 0), new TileZXY(1, 0, 1)),
Arguments.of(new Bounds(-85.0511288, 0, 0, 180), new TileZXY(1, 1, 1)),
Arguments.of(new Bounds(0, 0, 0.0006866, 0.0006866), new TileZXY(19, 262144, 262143)),
Arguments.of(new Bounds(0, -0.0006866, 0.0006866, 0), new TileZXY(19, 262143, 262143)),
Arguments.of(new Bounds(-0.0006866, -0.0006866, 0, 0), new TileZXY(19, 262143, 262144)),
Arguments.of(new Bounds(-0.0006866, 0, 0, 0.0006866), new TileZXY(19, 262144, 262144))
);
}

@ParameterizedTest
@MethodSource
void testBBoxCalculation(Bounds expected, TileZXY tile) {
assertEquals(expected, TileZXY.tileToBounds(tile));
}

static Stream<Arguments> testTileFromLatLon() {
final double delta = 0.00001; // Purely to get off of tile boundaries
return testBBoxCalculation().flatMap(arg -> {
final Bounds expected = (Bounds) arg.get()[0];
final TileZXY tile = (TileZXY) arg.get()[1];
return Stream.of(Arguments.of("UL", expected.getMaxLat() - delta, expected.getMinLon() + delta, tile),
Arguments.of("LL", expected.getMinLat() + delta, expected.getMinLon() + delta, tile),
Arguments.of("UR", expected.getMaxLat() - delta, expected.getMaxLon() - delta, tile),
Arguments.of("LR", expected.getMinLat() + delta, expected.getMaxLon() - delta, tile),
Arguments.of("Center", expected.getCenter().lat(), expected.getCenter().lon(), tile));
});
}

@ParameterizedTest(name = "{3} - {0}")
@MethodSource
void testTileFromLatLon(String description, double lat, double lon, TileZXY tile) {
assertEquals(tile, TileZXY.latLonToTile(lat, lon, tile.zoom()));
}

@Test
void testEqualsContract() {
EqualsVerifier.forClass(TileZXY.class).verify();
}
}

0 comments on commit 3763d44

Please sign in to comment.