本文干活满满,直接用代码,给大家完整展示如何用java编写一个自主可控的地图瓦片下载程序,并分级合并瓦片为大图,以便在geoserver中部署自己的瓦片地图。
本文涉及的环境如下:
操作系统:windows 11
Java JDK:OpenJDK21
构建工具:Gradle 8.4
开发工具:VsCode - Visual Studio Code 1.84.1
关键组件:
1、org.apache.httpcomponents.client5:httpclient5:5.1.3 网络请求组件、下载文件等
2、opencv 4.8 图片处理组件
第三方资源:天地图
1、项目代码结构
核心代码由一个入口程序类、两个工具类、一个模型类组成。
2、配置项目build.gradle
按以下配置设置,以支持org.apache.httpcomponents.client5:httpclient5和opencv,关于opencv的详情引入方式可参考本人另一篇文章《JAVA新实战3:opencv+java应用初探》。
build.gradle完整代码如下:
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
}
group = 'com.jojava'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
//阿里
maven { url 'https://maven.aliyun.com/nexus/content/groups/public/' }
mavenCentral()
}
dependencies {
implementation 'commons-io:commons-io:2.+'
implementation 'org.apache.httpcomponents.client5:httpclient5:5.1.3'
implementation(files("lib/opencv/opencv-480.jar"))
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
3、编写瓦片任务模型类
创建包“com.jojava.joMapTile.model”,在包下创建“TaskBlockDivide”类,该模型类定义任务块实体结构,代码如下:
package com.jojava.joMapTile.model;
import java.util.ArrayList;
import lombok.Data;
@Data
public class TaskBlockDivide {
private Long countX;
private Long countY;
private ArrayList<Long[]> divideX;
private ArrayList<Long[]> divide;
}
4、编写瓦片xy坐标生成工具类
创建包“com.jojava.joMapTile.utils”,在包下创建“TaskUtils”类,该工具类用于生成瓦片xy坐标和瓦片序号,代码如下:
package com.jojava.joMapTile.utils;
import java.util.ArrayList;
import com.jojava.joMapTile.model.TaskBlockDivide;
/**
* 瓦片任务块xy坐标、编号生成
*
* @author lyd
* @date 2023-11-08
*/
public class TaskUtils {
public static TaskBlockDivide blockDivide(long xStart, long xEnd, long yStart, long yEnd, double d) {
TaskBlockDivide divide = new TaskBlockDivide();
long countX = xEnd - xStart + 1;
long countY = yEnd - yStart + 1;
int eachX = (int) Math.ceil(countX / d);
int eachY = (int) Math.ceil(countY / d);
if (countX <= d) {
eachX = (int) Math.ceil(countX / 2);
eachX = eachX == 0 ? 1 : eachX;
}
if (countY <= d) {
eachY = (int) Math.ceil(countY / 2);
eachY = eachY == 0 ? 1 : eachY;
}
ArrayList<Long[]> divideX = new ArrayList<>();
ArrayList<Long[]> divideY = new ArrayList<>();
if (countX / eachX <= 1) {
Long arr[] = { 0L, countX };
divideX.add(arr);
} else {
long cnt = (int) Math.floor(countX / eachX);
long e = countX % eachX;
for (var i = 0L; i < cnt; i++) {
Long arr[] = { i * eachX, (i + 1) * eachX - 1 };
divideX.add(arr);
}
if (cnt * eachX < cnt * eachX + e) {
Long arrEnd[] = { cnt * eachX, cnt * eachX + e - 1 };
divideX.add(arrEnd);
}
}
if (countY / eachY <= 1) {
Long arr[] = { 0L, countY };
divideY.add(arr);
} else {
long cnt = (int) Math.floor(countY / eachY);
long e = countY % eachY;
for (var i = 0L; i < cnt; i++) {
Long arr[] = { i * eachY, (i + 1) * eachY - 1 };
divideY.add(arr);
}
if (cnt * eachY < cnt * eachY + e) {
Long arrEnd[] = { cnt * eachY, cnt * eachY + e - 1 };
divideY.add(arrEnd);
}
}
divide.setCountX(countX);
divide.setCountY(countY);
divide.setDivideX(divideX);
divide.setDivideY(divideY);
return divide;
}
}
5、编写瓦片合并大图工具类
在包“com.jojava.joMapTile.utils”下创建“TileMergeUtils”类,该工具类用于合并各层级瓦片图片为该层级单张大图,代码如下:
package com.jojava.joMapTile.utils;
import java.io.File;
import java.io.IOException;
import org.apache.commons.io.FileUtils;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.core.Range;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;
import lombok.Getter;
/**
* 合并瓦片为大图 tiff
*
* @author lyd
* @date 2023-11-08
*/
public class TileMergeUtils {
private Mat des = null;
private int tileWidth = 256; // 瓦片大小 宽
private int tileHeight = 256; // 瓦片大小 高
@Getter
private long allPixel = 0L;
@Getter
private long runPixel = 0L;
public void init(int width, int height) {
/*
* CV_8uc1 单颜色通道 8位</br>
* CV_8uc2 2颜色通道 16位</br>
* CV_8uc3 3颜色通道 24位</br>
* CV_8uc4 4颜色通道 32位</br>
*/
// CV_8UC4为支持透明PNG的RGBA格式
this.des = Mat.zeros(height, width, CvType.CV_8UC4);
// 计算总像素数量
this.allPixel = (long) width * height;
}
public void mergeToMat(String pathAndName, long x, long y) {
// 读取图片
var tileMat = Imgcodecs.imread(pathAndName, Imgcodecs.IMREAD_UNCHANGED);
try {
// 转换图片至RGBA格式
Imgproc.cvtColor(tileMat, tileMat, Imgproc.COLOR_BGR2BGRA);
// 确定坐标位置
var rectForDes = this.des
.colRange(new Range((int) x, (int) x + tileWidth))
.rowRange(new Range((int) y, (int) y + tileHeight));
// 填充至合并大图
tileMat.copyTo(rectForDes);
} catch (Exception ignored) {
}
// 完成后计算已合并的像素数量
this.runPixel += (long) tileWidth * tileHeight;
}
public void output(String path, String name) throws IOException {
String suffix = "tiff";
String out = path + name + "." + suffix;
FileUtils.createParentDirectories(new File(out));
Imgcodecs.imwrite(out, this.des);
}
public void destroy() {
this.des.release();
this.des = null;
}
}
5、编写项目启动运行程序
编辑项目默认的JoMapTileApplication,本案瓦片下载和合并调用代码均写在这里,大家使用时可直接拿走用,也可自行进行封装,目前这里只实现了天地图的瓦片下载,其他注入百度地图、腾讯地图等瓦片下载原理差不多,后续将逐步补上。
当前启动及瓦片下载程序代码如下:
package com.jojava.joMapTile;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpStatus;
import com.jojava.joMapTile.utils.TaskUtils;
import com.jojava.joMapTile.utils.TileMergeUtils;
public class JoMapTileApplication {
// 引入 opencv_java480 组件
static {
// windows
System.load(System.getProperty("user.dir") + "/lib/opencv/opencv_java480.dll");
// mac
// System.load(System.getProperty("user.dir") +
// "/lib/opencv/opencv_java480.dylib");
// linux
// System.load(System.getProperty("user.dir") +
// "/lib/opencv/opencv_java480.so");
}
// 地图品牌
public static String mapBrand = "tianditu"; // 默认为天地图,当前主要支持
// 地图key
public static String mapKey = "4913045c9f99f6b423d4027d5fb9658b"; // 有些地图需要key才能使用,如:天地图,请自行申请
// http://lbs.tianditu.gov.cn/server/MapService.html
// 地图类型
public static String mapType = "img_w"; // 各个地图品牌都有各自不同的地图类型
// 瓦片下载保存的本地主目录
public static String basePath = "E:/tianditu";
public static String[] servers = { "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7" };
public static int minZoom = 1; // 下载开始层级
public static int maxZoom = 18; // 下载结束层级
// 天安门 116.320303,39.964566 116.465182,39.871597
public static double startLat = 39.964566;// 开始纬度(从北到南)
public static double endLat = 39.871597;// 结束纬度(从北到南)
public static double startLon = 116.320303;// 开始经度(从西到东)
public static double endLon = 116.465182;// 结束经度(从西到东)
// 坐标
// public static double startLat = 44.092424;// 开始纬度(从北到南)
// public static double endLat = 43.940986;// 结束纬度(从北到南)
// public static double startLon = 126.045746;// 开始经度(从西到东)
// public static double endLon = 126.516601;// 结束经度(从西到东)
public static void main(String[] args) {
// SpringApplication.run(TiandituApplication.class, args);
switch (mapBrand) {
case "tianditu":
TianDiTu();
break;
case "baidu":
BaiduMap();
break;
case "gaode":
GaodeMap();
break;
case "tencent":
TencentMap();
break;
default:
TianDiTu();
}
}
// 天地图 - 目前仅支持这一款
public static void TianDiTu() {
// 注意天地图API访问次数限制
String tk = mapKey;
String type = mapType;
// 影像 - 墨卡托
String img_w = "http://{server}.tianditu.gov.cn/" + type
+ "/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={tk}";
// String img_w =
// "http://{server}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={tk}";
// 平面 - 经纬度投影
// 影像标注 - 墨卡托
// public static String img_w =
// "http://{server}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={tk}";
String[] urlArr = { img_w };// 要下载的图层
// 各级瓦片范围
Map<String, Object> tilesMap = new HashMap<String, Object>();
// 启动多线程
ExecutorService exe = Executors.newFixedThreadPool(6);
// 等经纬度第一层是1x2,纬度数量是2^0,经度数量是2^1
// 墨卡托投影第一层是2x2,纬度数量是2^1,经度数量是2^1
for (int i = 0; i < urlArr.length; i++) {
String url = urlArr[i].replace("{tk}", tk);
System.out.println(url);
String layerName = url.split("tianditu.gov.cn/")[1].split("/wmts?")[0];
for (int z = minZoom; z <= maxZoom; z++) {
double deg = 360.0 / Math.pow(2, z) / 256;// 一个像素点代表多少度
int startX = (int) ((startLon + 180) / deg / 256);
int endX = (int) ((endLon + 180) / deg / 256);
int startY = (((int) Math.pow(2, z) * 256 / 2)
- (int) ((Math.log(Math.tan((90 + startLat) * Math.PI / 360)) / (Math.PI / 180))
/ (360 / Math.pow(2, z) / 256) + 0.5))
/ 256;
int endY = (((int) Math.pow(2, z) * 256 / 2)
- (int) ((Math.log(Math.tan((90 + endLat) * Math.PI / 360)) / (Math.PI / 180))
/ (360 / Math.pow(2, z) / 256) + 0.5))
/ 256;
// 将计算好的瓦片编号范围存
tilesMap.put(String.valueOf(z), String.valueOf(startX) + "," + String.valueOf(endX) + ","
+ String.valueOf(startY) + "," + String.valueOf(endY));
// 循环下载瓦片
for (int x = startX; x <= endX; x++) {
for (int y = startY; y <= endY; y++) {
final String newUrl = url.replace("{server}", servers[(int) (Math.random() * servers.length)])
.replace("{z}", z + "").replace("{x}", x + "").replace("{y}", y + "");
System.out.println(newUrl);
final String filePath = basePath + "/" + layerName + "/" + z + "/" + x + "/" + y + ".png";
exe.execute(new Runnable() {
@Override
public void run() {
File file = new File(filePath);
if (!file.exists()) {
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
boolean loop = true;
int count = 0;
while (loop && count < 5) {// 下载出错进行重试,最多5次
count++;
try {
InputStream in = getFileInputStream(newUrl);
OutputStream out = new BufferedOutputStream(new FileOutputStream(file));
byte[] b = new byte[8192];
int len = 0;
while ((len = in.read(b)) > -1) {
out.write(b, 0, len);
out.flush();
}
out.close();
in.close();
loop = false;
} catch (Exception e) {
loop = true;
}
}
if (loop) {
System.out.println("下载失败:" + newUrl);
}
}
}
});
}
}
// 合并本层级瓦片
System.out.println("本层最后一组 " + endX);
mergeTileImage(layerName, z, startX, endX, startY, endY);
}
}
exe.shutdown();
while (true) {
try {
Thread.sleep(1000L);// 主线程休眠1秒,等待线程池运行结束,同时避免一直死循环造成CPU浪费
} catch (InterruptedException e) {
}
if (exe.isTerminated()) {// 线程池所有线程都结束运行
break;
}
}
}
// 百度地图
public static void BaiduMap() {
// 暂略
}
// 腾讯地图
public static void TencentMap() {
// 暂略
}
// 高德地图
public static void GaodeMap() {
// 暂略
}
// 获取文件下载流
public static InputStream getFileInputStream(String url) throws Exception {
InputStream is = null;
CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet request = new HttpGet(url);
request.setHeader("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
CloseableHttpResponse response = httpclient.execute(request);
response.setHeader("Content-Type", "application/octet-stream");
int statusCode = response.getCode();
if (statusCode == HttpStatus.SC_OK) {
HttpEntity entity = response.getEntity();
is = entity.getContent();
}
return is;
}
// 合并图片 指定瓦片范围
public static void mergeTileImage(String layerName, int zoom, long xStart, long xEnd, long yStart,
long yEnd) {
TileMergeUtils mat = new TileMergeUtils();
int tileWidth = 256; // 瓦片大小 宽
int tileHeight = 256; // 瓦片大小 高
String savePath = basePath + "/" + layerName;
if (zoom == 0) {
return;
}
try {
System.out.print("正在合并第" + zoom + "级地图");
int z = zoom;
long mergeImageWidth = tileWidth * (xEnd - xStart + 1);
long mergeImageHeight = tileHeight * (yEnd - yStart + 1);
if (mergeImageWidth >= Integer.MAX_VALUE || mergeImageHeight >= Integer.MAX_VALUE) {
System.out.print("合并后的图片width:" + mergeImageWidth + ",height:" + mergeImageHeight + ",宽度或高度大于int最大值"
+ Integer.MAX_VALUE + ",不予合并。");
return;
}
var WH = mergeImageWidth * mergeImageHeight;
System.out.print("合并后的图片width:" + mergeImageWidth + ",height:" + mergeImageHeight + ",像素大小:"
+ WH);
if (WH > (long) Integer.MAX_VALUE) {
System.out
.print("该" + zoom + "级地图合并后像素大小大于int最大值" + Integer.MAX_VALUE + ",合并时间可能会稍长,建议低配置电脑不要执行超大尺寸合并");
}
// 开启线程
mat.init((int) mergeImageWidth, (int) mergeImageHeight);
var cpuCoreCount = Runtime.getRuntime().availableProcessors();
var d = Math.floor(Math.sqrt(cpuCoreCount));
var divide = TaskUtils.blockDivide(xStart, xEnd, yStart, yEnd, d);
var divideX = divide.getDivideX();
var divideY = divide.getDivideY();
// 循环读取图层,进行合并
for (var i = 0; i < divideX.size(); i++) {
for (var j = 0; j < divideY.size(); j++) {
// 计算点像素位置
long topLeftX = xStart, topLeftY = yStart;
long xStartPosit = xStart + divideX.get(i)[0];
long xEndPosit = xStart + divideX.get(i)[1];
long yStartPosit = yStart + divideY.get(j)[0];
long yEndPosit = yStart + divideY.get(j)[1];
//
System.out.println("xEndPosit: " + xEndPosit);
for (var x = xStartPosit; x < xEndPosit; x++) {
if (Thread.currentThread().isInterrupted()) {
break;
}
for (var y = yStartPosit; y <= yEndPosit; y++) {
if (Thread.currentThread().isInterrupted()) {
break;
}
var positionX = tileWidth * (x - topLeftX);
var positionY = tileWidth * (y - topLeftY);
var filePathAndName = savePath + "/" + z + "/" + x + "/" + y + ".png";
System.out.println(filePathAndName);
mat.mergeToMat(filePathAndName, positionX, positionY);
}
}
}
}
System.out.print("正在写入至硬盘...");
// 合并后图片的保存位置
var outPath = savePath + "/merge" + "/";
var outName = "z" + z;
// 输出合并后的图片
mat.output(outPath, outName);
mat.destroy();
System.out.print("第" + zoom + "级地图合并完成");
} catch (Exception e) {
e.printStackTrace();
}
}
}
6、启动运行程序
点击启动程序中的“Run”启动运行,即可实现自动下载瓦片,并在每个层级瓦片下载完毕后执行合并。
7、代码仓
本文章项目代码已开源到gitee,请进入下载。
joMapTile: 一个基于java编写的地图瓦片下载及瓦片合并大图小工具 (gitee.com)https://gitee.com/duihao/jomaptile
8、结语
本人正在努力持续不断为大家分享JAVA相关的各种实战经验,所有技术尽可能采用较新且成熟的技术架构,期待能够帮助到您。
如果您觉得本文对你有所帮助和启发,感谢您不吝赐教,也动动您发财的小手给予作者鼓励,谢谢,你的每一个点赞、评论、收藏都是对我莫大的鼓励,不足之处也请朋友们多多赐教。