/* * Copyright (C) 2014 Alec Dhuse * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package co.foldingmap.mapImportExport; import co.foldingmap.GUISupport.ProgressBarPanel; import co.foldingmap.Logger; import co.foldingmap.MainWindow; import co.foldingmap.map.DigitalMap; import co.foldingmap.map.MapView; import co.foldingmap.map.tile.TileMath; import co.foldingmap.map.tile.TileReference; import co.foldingmap.map.vector.LatLonAltBox; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.security.MessageDigest; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.Statement; import javax.imageio.ImageIO; /** * Class to export map as map tiles. Supports nested directories and MbTiles format. * * @author Alec Dhuse */ public class TileExporter extends Thread { public static final int TILESIZE = 256; public static final int NESTEDFOLDERS = 1; public static final int MBTILES = 2; private DigitalMap mapData; private File export; private int maxZoom, minZoom, exportMethod; private LatLonAltBox bounds; private MainWindow mainWindow; public TileExporter(DigitalMap mapData, LatLonAltBox bounds, int minZoom, int maxZoom, int exportMethod, File export, MainWindow mainWindow) { this.mapData = mapData; this.bounds = bounds; this.minZoom = minZoom; this.maxZoom = maxZoom; this.exportMethod = exportMethod; this.export = export; this.mainWindow = mainWindow; } /** * Calculates the number of tiles that would be generated from the bounds * zoom level specified. * * @param bounds * @param zoomMin * @param zoomMax * @return */ public static long calculateNumberOfMapTiles(LatLonAltBox bounds, int zoomMin, int zoomMax) { long numberOfTiles, boundsWidth, boundsHeight; TileReference northWest, southEast; numberOfTiles = 0; for (int z = zoomMin; z <= zoomMax; z++) { northWest = TileReference.getTileReference(bounds.getNorth(), bounds.getWest(), z); southEast = TileReference.getTileReference(bounds.getSouth(), bounds.getEast(), z); boundsWidth = (southEast.getX() - northWest.getX()); boundsHeight = (southEast.getY() - northWest.getY()); numberOfTiles += (boundsWidth * boundsHeight); } return numberOfTiles; } /** * Static method for creating a BufferedImage of a map with a given MapView. * * @param mapData * @param mapView * @param renderAntialiasing * @return */ public static BufferedImage createTileImage(DigitalMap mapData, MapView mapView, RenderingHints renderAntialiasing, int height, int width) { BufferedImage exportBufferedImage; Graphics2D exportGraphics2D; //setup image buffer exportBufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); exportGraphics2D = exportBufferedImage.createGraphics(); exportGraphics2D.setRenderingHints(renderAntialiasing); //draw background exportGraphics2D.setColor(mapData.getTheme().getBackgroundColor()); exportGraphics2D.fill(new Rectangle2D.Float(-1, -1, width + 1, height + 1)); //draw the map mapData.drawMap(exportGraphics2D, mapView); return exportBufferedImage; } /** * Exports map as tiles to nested directories * * @param mapData * @param bounds * @param minZoom * @param maxZoom * @param dir * @param mainWindow */ public static void exportTilesToDIR(DigitalMap mapData, LatLonAltBox bounds, int minZoom, int maxZoom, File dir, MainWindow mainWindow) { BufferedImage exportBufferedImage; File currentTileFile; double tileLatitude, tileLongitude; float numberOfTiles, tilesCreated, vectorMapZoom; int percent; MapView mapView; ProgressBarPanel progressBar; RenderingHints renderAntialiasing; String progressText; TileReference maxRef, minRef; try { //Prime the MapView mapView = new MapView(); mapView.setDisplayAll(true); mapView.getMapProjection().setDisplaySize(TILESIZE, TILESIZE); //setup rendering renderAntialiasing = new RenderingHints(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); renderAntialiasing.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); progressBar = mainWindow.getProgressBarPanel(); numberOfTiles = calculateNumberOfMapTiles(bounds, minZoom, maxZoom); tilesCreated = 0; for (int z = minZoom; z <= maxZoom; z++) { //This will take a lot of memory clean up what we can first System.gc(); //setup the zoom vectorMapZoom = TileMath.getVectorMapZoom(z); mapView.getMapProjection().setZoomLevel(vectorMapZoom); //find the tile bounds minRef = TileReference.getTileReference(bounds.getNorth(), bounds.getWest(), z); maxRef = TileReference.getTileReference(bounds.getSouth(), bounds.getEast(), z); maxRef.incrementY(); //Prime the MapView mapView = new MapView(); mapView.setDisplayAll(true); mapView.getMapProjection().setDisplaySize(TILESIZE, TILESIZE); vectorMapZoom = TileMath.getVectorMapZoom(z); mapView.getMapProjection().setZoomLevel(vectorMapZoom); //create tiles for (int x = minRef.getX(); x < maxRef.getX(); x++) { for (int y = minRef.getY(); y < maxRef.getY(); y++) { if (progressBar != null) { progressText = "Exporting - Z: " + z + " X: " + x + " Y: " + y; percent = (int) ((tilesCreated / numberOfTiles) * 100); progressBar.updateProgress(progressText, percent); } tileLatitude = getTileLatitude(x, y, z); tileLongitude = getTileLongitude(x, y, z); mapView.getMapProjection().setReference(tileLatitude, tileLongitude); exportBufferedImage = createTileImage(mapData, mapView, renderAntialiasing, TILESIZE, TILESIZE); currentTileFile = new File(dir, z + "/" + x + "/" + y + ".png"); currentTileFile.mkdirs(); try { ImageIO.write(exportBufferedImage, "PNG", currentTileFile); } catch (Exception e) { Logger.log(Logger.ERR, "Counld not create tile z: " + z + " x: " + x + " y: " + y + " Error: " + e); } tilesCreated++; } } } progressBar.finish(); } catch (Exception e) { Logger.log(Logger.ERR, "Error in TileExporter.exportTilesToDIR(DigitalMap, LatLonAltBox, int, int, File, MainWindow) - " + e); } } /** * Exports as map tiles to MbTiles format. * * @param mapData * @param bounds * @param minZoom * @param maxZoom * @param dbFile * @param mainWindow * @see <a href="https://github.com/mapbox/mbtiles-spec">MbTiles Spec</a> */ public static void exportTilesToMbTiles(DigitalMap mapData, LatLonAltBox bounds, int minZoom, int maxZoom, File dbFile, MainWindow mainWindow) { BufferedImage exportBufferedImage; ByteArrayOutputStream baos; byte[] tileImageBytes; Connection conn; double tileLatitude, tileLongitude; float numberOfTiles, tilesCreated, vectorMapZoom; int percent; MapView mapView; PreparedStatement prep; ProgressBarPanel progressBar; RenderingHints renderAntialiasing; Statement stat; String boundsString, filePathName, imageHash; String progressText; TileReference maxRef, minRef; try { //Prime the MapView mapView = new MapView(); mapView.setDisplayAll(true); mapView.getMapProjection().setDisplaySize(TILESIZE, TILESIZE); //setup rendering renderAntialiasing = new RenderingHints(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); renderAntialiasing.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); progressBar = mainWindow.getProgressBarPanel(); numberOfTiles = calculateNumberOfMapTiles(bounds, minZoom, maxZoom); tilesCreated = 0; filePathName = dbFile.getCanonicalPath(); boundsString = bounds.getWest() + "," + bounds.getSouth() + "," + bounds.getEast() + "," + bounds.getNorth(); Class.forName("org.sqlite.JDBC"); conn = DriverManager.getConnection("jdbc:sqlite:" + filePathName); stat = conn.createStatement(); prep = conn.prepareStatement(""); //create the tables stat.executeUpdate("create table if not exists metadata (name text, value text);"); stat.executeUpdate("create table if not exists map (zoom_level integer, tile_column integer, tile_row integer, tile_id text, grid_id text);"); stat.executeUpdate("create table if not exists grid_key (grid_id text, key_name text);"); stat.executeUpdate("create table if not exists keymap (key_name text, key_json text);"); stat.executeUpdate("create table if not exists grid_uftgrid (grid_id text, grid_uftgrid blob);"); stat.executeUpdate("create table if not exists images (tile_data blob, tile_id text);"); //create indexes stat.executeUpdate("CREATE UNIQUE INDEX if not exists map_index ON map (zoom_level, tile_column, tile_row);"); stat.executeUpdate("CREATE UNIQUE INDEX if not exists grid_key_lookup ON grid_key (grid_id, key_name);"); stat.executeUpdate("CREATE UNIQUE INDEX if not exists keymap_lookup ON keymap (key_name);"); stat.executeUpdate("CREATE UNIQUE INDEX if not exists grid_uftgrid_lookup ON grid_uftgrid (grid_id);"); stat.executeUpdate("CREATE UNIQUE INDEX if not exists images_id ON images (tile_id);"); stat.executeUpdate("CREATE UNIQUE INDEX if not exists name ON metadata (name);"); //create view to mimic the tiles table that appears in the spec stat.executeUpdate("CREATE VIEW if not exists tiles AS SELECT m.zoom_Level zoom_level, m.tile_column tile_column, m.tile_row tile_row, i.tile_data tile_data FROM map m, images i WHERE m.tile_id = i.tile_id;"); //update metadata updateMetadata(mapData, conn, boundsString, minZoom, maxZoom); //create the tiles and add them into the DB for (int z = minZoom; z <= maxZoom; z++) { //This will take a lot of memory clean up what we can first System.gc(); //setup the zoom vectorMapZoom = TileMath.getVectorMapZoom(z); mapView.getMapProjection().setZoomLevel(vectorMapZoom); //find the tile bounds minRef = TileReference.getTileReference(bounds.getNorth(), bounds.getWest(), z); maxRef = TileReference.getTileReference(bounds.getSouth(), bounds.getEast(), z); //Make sure we get at least one tile from each requested zoom level. maxRef.incrementY(); if (minRef.getX() == maxRef.getX()) maxRef.incrementX(); //create tiles for (int x = minRef.getX(); x < maxRef.getX(); x++) { for (int y = minRef.getY(); y < maxRef.getY(); y++) { // if (progressBar != null) { progressText = "Exporting - Zoom: " + z + " X: " + x + " Y: " + y; percent = (int) ((tilesCreated / numberOfTiles) * 100); progressBar.updateProgress(progressText, percent); } tileLatitude = getTileLatitude(x, y, z); tileLongitude = getTileLongitude(x, y, z); mapView.getMapProjection().setReference(tileLatitude, tileLongitude); //get the tile image and reate an input stream to add it to the db; exportBufferedImage = createTileImage(mapData, mapView, renderAntialiasing, TILESIZE, TILESIZE); baos = new ByteArrayOutputStream(); ImageIO.write(exportBufferedImage, "png", baos); tileImageBytes = baos.toByteArray(); //hash the image imageHash = hashBytes(tileImageBytes); /* The actual Images are stored here, referenced by the * hash. If an image is just empty ocean there will be * a hash colision and thus saving space in the db. */ prep = conn.prepareStatement("INSERT OR IGNORE into images values (?, ?);"); prep.setBytes(1, tileImageBytes); prep.setString(2, imageHash); prep.addBatch(); conn.setAutoCommit(false); prep.executeBatch(); conn.setAutoCommit(true); prep = conn.prepareStatement("INSERT OR REPLACE into map values (?, ?, ?, ?, ?);"); prep.setInt(1, z); prep.setInt(2, x); prep.setInt(3, (maxRef.getY() - y) - 1); //MBTiles y is reversed prep.setString(4, imageHash); prep.setString(5, " "); prep.addBatch(); conn.setAutoCommit(false); prep.executeBatch(); conn.setAutoCommit(true); tilesCreated++; } } } progressBar.updateProgress("Export Complete", 100); progressBar.finish(); } catch (Exception e) { Logger.log(Logger.ERR, "Error in TileExporter.exportTilesToMbTiles(DigitalMap, LatLonAltBox, int, int, File, MainWindow) - " + e); } } /** * Finds the zoom level that would result in the whole map being displayed * in one tile. * * @param bounds * @return */ public static int findMinZoom(LatLonAltBox bounds) { int minZoom; long tileNumbers; minZoom = 1; for (int i = 23; i > 0; i--) { tileNumbers = calculateNumberOfMapTiles(bounds, i, i); if (tileNumbers == 1) { minZoom = i; break; } } return minZoom; } /** * Returns the top Latitude of the specified tile; * * @param x * @param y * @param z * @return */ public static double getTileLatitude(int x, int y, int z) { double latDeg, latRad, n; n = Math.pow(2, z); latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))); latDeg = latRad * 180.0 / Math.PI; return latDeg; } /** * Returns the Left Longitude of the specified tile. * * @param x * @param y * @param z * @return */ public static double getTileLongitude(int x, int y, int z) { double lonDeg, n; n = Math.pow(2, z); lonDeg = x / n * 360.0 - 180.0; return lonDeg; } /** * Creates a MD5 hash of an array of bytes. * * @param bytes * @return */ public static String hashBytes(byte[] bytes) { byte[] digestedBytes; MessageDigest messageDigest; StringBuffer hexString; hexString = new StringBuffer(); try { messageDigest = MessageDigest.getInstance("MD5"); messageDigest.reset(); messageDigest.update(bytes); digestedBytes = messageDigest.digest(); for (int i=0; i < digestedBytes.length; i++) hexString.append(Integer.toHexString(0xFF & digestedBytes[i])); } catch (Exception e) { Logger.log(Logger.ERR, "Error in TileExporter.hashBytes(Byte[] bytes) - e"); } return hexString.toString(); } /** * Splits a large map rendering in to a series of smaller tiles. * * @param bi * @param tileSize * @return */ public static BufferedImage[][] splitImage(BufferedImage bi, int tileSize) { int rows = bi.getHeight() / tileSize; int cols = bi.getWidth() / tileSize; BufferedImage[][] images = new BufferedImage[cols][rows]; for (int x = 0; x < cols; x++) { int xOffset = x * tileSize; for (int y = 0; y < rows; y++) { int yOffset = y * tileSize; images[x][y] = new BufferedImage(tileSize, tileSize, bi.getType()); Graphics2D gr = images[x][y].createGraphics(); gr.drawImage(bi, 0, 0, tileSize, tileSize, yOffset, xOffset, yOffset + tileSize, xOffset + tileSize, null); gr.dispose(); } } return images; } /** * Starts the thread to export the map as tiles. * */ @Override public void run() { if (exportMethod == TileExporter.NESTEDFOLDERS) { exportTilesToDIR(mapData, bounds, minZoom, maxZoom, export, mainWindow); } else if (exportMethod == TileExporter.MBTILES) { exportTilesToMbTiles(mapData, bounds, minZoom, maxZoom, export, mainWindow); } } /** * Updates the Metadata table in the mbtiles SQL light database. * * @param mapData * @param conn * @param boundsString * @param minZoom * @param maxZoom */ private static void updateMetadata(DigitalMap mapData, Connection conn, String boundsString, int minZoom, int maxZoom) { try { PreparedStatement prep = conn.prepareStatement("insert into metadata values (?, ?);"); prep.setString(1, "name"); prep.setString(2, mapData.getName()); prep.addBatch(); prep.setString(1, "type"); prep.setString(2, "baselayer"); prep.addBatch(); prep.setString(1, "version"); prep.setString(2, mapData.getVersionNumber()); prep.addBatch(); prep.setString(1, "minzoom"); prep.setString(2, Integer.toString(minZoom)); prep.addBatch(); prep.setString(1, "maxzoom"); prep.setString(2, Integer.toString(maxZoom)); prep.addBatch(); prep.setString(1, "center"); prep.setString(2, "0,0,2"); prep.addBatch(); prep.setString(1, "description"); prep.setString(2, mapData.getMapDescription()); prep.addBatch(); prep.setString(1, "format"); prep.setString(2, "png"); prep.addBatch(); prep.setString(1, "bounds"); prep.setString(2, boundsString); prep.addBatch(); conn.setAutoCommit(false); prep.executeBatch(); conn.setAutoCommit(true); } catch (Exception e) { Logger.log(Logger.ERR, "Error in TileExporter.updateMetadata(DigitalMap, Connection, String, int, int) - " + e); } } }