tilekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tilekit/__init__.py +1 -0
- tilekit/downloader.py +413 -0
- tilekit/error_info.py +3 -0
- tilekit-0.1.0.dist-info/METADATA +212 -0
- tilekit-0.1.0.dist-info/RECORD +6 -0
- tilekit-0.1.0.dist-info/WHEEL +4 -0
tilekit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .downloader import TileDownloader
|
tilekit/downloader.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Author: ZPZ
|
|
3
|
+
# Description: Core module for tile downloading and concurrency management.
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable, List, Optional, Sequence, Union
|
|
15
|
+
import rasterio
|
|
16
|
+
from rasterio.enums import Compression
|
|
17
|
+
from rasterio.transform import from_bounds
|
|
18
|
+
import mercantile
|
|
19
|
+
import numpy as np
|
|
20
|
+
import requests
|
|
21
|
+
from PIL import Image
|
|
22
|
+
from tqdm import tqdm
|
|
23
|
+
from .error_info import error_img_bytes, error_img_bytes2
|
|
24
|
+
|
|
25
|
+
# 类型别名
|
|
26
|
+
Number = Union[float, int]
|
|
27
|
+
ArrayLike = Union[Number, np.ndarray]
|
|
28
|
+
Tile = mercantile.Tile
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TileDownloader:
|
|
33
|
+
"""
|
|
34
|
+
暂时只支持吉林一号 TMS 下载
|
|
35
|
+
|
|
36
|
+
- 假定服务使用 WebMercator 瓦片坐标 (z, x, y)
|
|
37
|
+
- mercantile 产生的是 XYZ(原点左上,y向下)
|
|
38
|
+
- JL1 接口使用 TMS 约定(原点左下,y向上),因此需要在请求前做 y 反转:
|
|
39
|
+
y_tms = (2**z - 1) - y_xyz
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# 要下载的瓦片列表(必需)
|
|
43
|
+
tile_list: Sequence[Tile]
|
|
44
|
+
|
|
45
|
+
# JL1 接口参数(如需自定义可以在实例化时传入)
|
|
46
|
+
mk: str = ""
|
|
47
|
+
tk: str = ""
|
|
48
|
+
|
|
49
|
+
# 缩放等级(可从 tile_list 推断,保留一个属性方便日志打印)
|
|
50
|
+
zoom: Optional[int] = None
|
|
51
|
+
|
|
52
|
+
# URL 模板:注意这里是 XYZ/TMS 坐标位,mk/tk 从属性填充
|
|
53
|
+
tile_url_template: str = (
|
|
54
|
+
"https://api.jl1mall.com/getMap/{z}/{x}/{y}"
|
|
55
|
+
"?mk={mk}&tk={tk}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _build_logger(self, output_folder: Path) -> logging.Logger:
|
|
59
|
+
"""
|
|
60
|
+
为当前下载任务构建 logger。
|
|
61
|
+
"""
|
|
62
|
+
os.makedirs(output_folder, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=logging.INFO,
|
|
66
|
+
format='[%(asctime)s] [%(levelname)s] %(message)s',
|
|
67
|
+
datefmt='%Y-%m-%d %H:%M:%S',
|
|
68
|
+
handlers=[
|
|
69
|
+
logging.StreamHandler(),
|
|
70
|
+
logging.FileHandler(
|
|
71
|
+
os.path.join(output_folder, 'download.log'),
|
|
72
|
+
encoding="utf-8"
|
|
73
|
+
),
|
|
74
|
+
],
|
|
75
|
+
force=True, # 覆盖旧配置
|
|
76
|
+
)
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
return logger
|
|
79
|
+
|
|
80
|
+
def _xyz_to_tms_y(self, z: int, y_xyz: int) -> int:
|
|
81
|
+
"""
|
|
82
|
+
将 XYZ 体系的 y 转为 TMS 体系的 y。
|
|
83
|
+
|
|
84
|
+
TMS 的 y 方向与 XYZ 相反:
|
|
85
|
+
y_tms = (2**z - 1) - y_xyz
|
|
86
|
+
"""
|
|
87
|
+
return (2 ** z - 1) - y_xyz
|
|
88
|
+
|
|
89
|
+
def precheck_downloaded(self, output_folder: Union[str, Path]) -> None:
|
|
90
|
+
"""
|
|
91
|
+
预检查:扫描 output_folder 中已下载的瓦片,根据文件名
|
|
92
|
+
tile_{z}_{x}_{y}.png 识别哪些 tile 已存在,并从 self.tile_list 中剔除。
|
|
93
|
+
|
|
94
|
+
最终:self.tile_list 将只包含“尚未下载”的 tiles。
|
|
95
|
+
"""
|
|
96
|
+
output_folder = Path(output_folder)
|
|
97
|
+
output_folder.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
# 列举目录中的现有瓦片文件
|
|
100
|
+
existing_files = list(output_folder.glob("*/*/*.tif"))
|
|
101
|
+
existing_set = set()
|
|
102
|
+
|
|
103
|
+
# 从文件名解析 z,x,y
|
|
104
|
+
for fp in existing_files:
|
|
105
|
+
name = fp.stem # tile_z_x_y
|
|
106
|
+
try:
|
|
107
|
+
# 获取倒数第3级目录(z)、倒数第2级目录(x)
|
|
108
|
+
z = int(fp.parent.parent.name)
|
|
109
|
+
x = int(fp.parent.name)
|
|
110
|
+
# 从文件名提取 y(去掉扩展名)
|
|
111
|
+
y = int(fp.stem)
|
|
112
|
+
existing_set.add((z, x, y))
|
|
113
|
+
except Exception:
|
|
114
|
+
# 文件名不是 tile_z_x_y.png 格式,则跳过
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# 分离:已下载 vs 未下载
|
|
118
|
+
remaining_tiles = []
|
|
119
|
+
downloaded = 0
|
|
120
|
+
|
|
121
|
+
for t in self.tile_list:
|
|
122
|
+
key = (t.z, t.x, t.y)
|
|
123
|
+
if key in existing_set:
|
|
124
|
+
downloaded += 1
|
|
125
|
+
else:
|
|
126
|
+
remaining_tiles.append(t)
|
|
127
|
+
|
|
128
|
+
# 更新 tile_list,仅保留待下载的
|
|
129
|
+
self.tile_list = remaining_tiles
|
|
130
|
+
|
|
131
|
+
# 打印检查结果
|
|
132
|
+
print(f"已存在瓦片: {downloaded}")
|
|
133
|
+
print(f"缺失瓦片: {len(self.tile_list)}")
|
|
134
|
+
print(f"总计(原始): {downloaded + len(self.tile_list)}\n")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def precheck_downloaded_old(self, output_folder: Union[str, Path]) -> None:
|
|
138
|
+
"""
|
|
139
|
+
预检查:扫描 output_folder 中已下载的瓦片,根据文件名
|
|
140
|
+
tile_{z}_{x}_{y}.png 识别哪些 tile 已存在,并从 self.tile_list 中剔除。
|
|
141
|
+
|
|
142
|
+
最终:self.tile_list 将只包含“尚未下载”的 tiles。
|
|
143
|
+
"""
|
|
144
|
+
output_folder = Path(output_folder)
|
|
145
|
+
output_folder.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
# 分离:已下载 vs 未下载
|
|
148
|
+
remaining_tiles = []
|
|
149
|
+
downloaded = 0
|
|
150
|
+
|
|
151
|
+
for t in self.tile_list:
|
|
152
|
+
png_path = output_folder / str(t.z) / str(t.x) / f"{t.y}.tif"
|
|
153
|
+
if os.path.isfile(png_path):
|
|
154
|
+
downloaded += 1
|
|
155
|
+
else:
|
|
156
|
+
remaining_tiles.append(t)
|
|
157
|
+
|
|
158
|
+
# 更新 tile_list,仅保留待下载的
|
|
159
|
+
self.tile_list = remaining_tiles
|
|
160
|
+
|
|
161
|
+
# 打印检查结果
|
|
162
|
+
print(f"已存在瓦片: {downloaded}")
|
|
163
|
+
print(f"缺失瓦片: {len(self.tile_list)}")
|
|
164
|
+
print(f"总计(原始): {downloaded + len(self.tile_list)}\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def human_delay(self, min_delay=0.06, max_delay=0.1):
|
|
168
|
+
"""
|
|
169
|
+
模仿人类行为:在请求之间增加随机延迟。
|
|
170
|
+
"""
|
|
171
|
+
delay = random.uniform(min_delay, max_delay)
|
|
172
|
+
|
|
173
|
+
# 加一点细小波动,让行为更自然、不规则
|
|
174
|
+
jitter = random.uniform(-0.05, 0.05)
|
|
175
|
+
delay = max(0.0, delay + jitter)
|
|
176
|
+
|
|
177
|
+
time.sleep(delay)
|
|
178
|
+
|
|
179
|
+
def download_tiles(
|
|
180
|
+
self,
|
|
181
|
+
output_folder: Union[str, Path],
|
|
182
|
+
tile_list: Optional[Sequence[Tile]] = None,
|
|
183
|
+
verbose_debug: bool = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""
|
|
186
|
+
下载当前实例中的所有瓦片。
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
output_folder : str | Path
|
|
191
|
+
瓦片输出目录。
|
|
192
|
+
tile_list : Optional[Sequence[Tile]]
|
|
193
|
+
可选:如果传入,将覆盖 self.tile_list,只下载这批瓦片。
|
|
194
|
+
verbose_debug : bool
|
|
195
|
+
如果为 True,会输出请求/响应/图像统计的详细调试信息。
|
|
196
|
+
"""
|
|
197
|
+
output_folder = Path(output_folder)
|
|
198
|
+
logger = self._build_logger(output_folder)
|
|
199
|
+
self.precheck_downloaded(output_folder)
|
|
200
|
+
|
|
201
|
+
tiles: Sequence[Tile] = tile_list if tile_list is not None else self.tile_list
|
|
202
|
+
|
|
203
|
+
if len(tiles) == 0:
|
|
204
|
+
logger.warning("Tile 列表为空,未执行任何下载。")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
logger.info(f"Zoom 级别: {self.zoom}")
|
|
208
|
+
logger.info(f"总瓦片数量 = 需要请求的次数 = {len(tiles)}")
|
|
209
|
+
|
|
210
|
+
pbar = tqdm(total=len(tiles), desc="Downloading tiles", ncols=100)
|
|
211
|
+
for idx, t in enumerate(tiles, start=1):
|
|
212
|
+
x_xyz, y_xyz, z = t.x, t.y, t.z
|
|
213
|
+
|
|
214
|
+
# mercantile 给的是 XYZ(原点左上),JL1 使用 TMS(原点左下)
|
|
215
|
+
y_tms = self._xyz_to_tms_y(z, y_xyz)
|
|
216
|
+
|
|
217
|
+
# 构造 URL
|
|
218
|
+
url = self.tile_url_template.format(
|
|
219
|
+
z=z,
|
|
220
|
+
x=x_xyz,
|
|
221
|
+
y=y_tms,
|
|
222
|
+
mk=self.mk,
|
|
223
|
+
tk=self.tk,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
logger.info(f"[{idx}/{len(tiles)}] 请求瓦片 z={z}, x={x_xyz}, y_xyz={y_xyz}, y_tms={y_tms}")
|
|
227
|
+
logger.debug(f"请求 URL: {url}")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
self.human_delay()
|
|
231
|
+
response = requests.get(url, timeout=10)
|
|
232
|
+
except requests.RequestException as e:
|
|
233
|
+
logger.error(f"请求失败(网络异常):{e}")
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
if verbose_debug:
|
|
237
|
+
print("\n请求的完整信息:")
|
|
238
|
+
print(f"请求的 URL: {response.request.url}")
|
|
239
|
+
print(f"请求头: {response.request.headers}")
|
|
240
|
+
print(f"请求方法: {response.request.method}")
|
|
241
|
+
|
|
242
|
+
print("\n响应的完整信息:")
|
|
243
|
+
print(f"状态码: {response.status_code}")
|
|
244
|
+
print(f"响应头: {response.headers}")
|
|
245
|
+
|
|
246
|
+
# 尝试区分 JSON / 图像
|
|
247
|
+
content_type = response.headers.get("Content-Type", "")
|
|
248
|
+
if response.status_code != 200:
|
|
249
|
+
logger.error(f"请求失败,状态码:{response.status_code}, URL={url}")
|
|
250
|
+
|
|
251
|
+
if "application/json" in content_type:
|
|
252
|
+
try:
|
|
253
|
+
logger.error(f"响应 JSON: {response.json()}")
|
|
254
|
+
except ValueError:
|
|
255
|
+
logger.error("响应看起来是 JSON,但解析失败。")
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if "image" not in content_type:
|
|
259
|
+
# 可能是 JSON 错误信息
|
|
260
|
+
try:
|
|
261
|
+
err_json = response.json()
|
|
262
|
+
logger.error(f"返回内容不是图像,JSON={err_json}")
|
|
263
|
+
except ValueError:
|
|
264
|
+
logger.error("返回内容不是图像,也不是 JSON。")
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
if response.content == error_img_bytes:
|
|
268
|
+
logger.error("限额了停止吧。")
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if response.content == error_img_bytes2:
|
|
272
|
+
logger.error("账号被禁用了停止吧。")
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
# 处理图像
|
|
276
|
+
img = Image.open(BytesIO(response.content))
|
|
277
|
+
logger.info(f"图像模式: {img.mode}, 图像大小: {img.size}")
|
|
278
|
+
|
|
279
|
+
if img.mode == "L":
|
|
280
|
+
img = img.convert("RGB")
|
|
281
|
+
if img.mode == "RGBA":
|
|
282
|
+
img = img.convert("RGB")
|
|
283
|
+
|
|
284
|
+
if verbose_debug:
|
|
285
|
+
arr = np.asarray(img)
|
|
286
|
+
print(f"mean: {np.mean(arr)}, unique: {np.unique(arr)[:10]} ...")
|
|
287
|
+
|
|
288
|
+
# 保存文件名仍然使用 XYZ 的 y,更符合用户习惯
|
|
289
|
+
out_name = output_folder / str(z) / str(x_xyz) / f"{y_xyz}.tif"
|
|
290
|
+
os.makedirs(os.path.dirname(out_name), exist_ok=True)
|
|
291
|
+
# img.save(out_name)
|
|
292
|
+
|
|
293
|
+
# 使用 mercantile 获取该瓦片的地理边界(西、南、东、北)
|
|
294
|
+
bounds = mercantile.bounds(mercantile.Tile(x=x_xyz, y=y_xyz, z=z))
|
|
295
|
+
|
|
296
|
+
# 创建 transform 从边界计算
|
|
297
|
+
transform = from_bounds(bounds.west, bounds.south, bounds.east, bounds.north, 256, 256) # 假设瓦片尺寸为 256x256
|
|
298
|
+
|
|
299
|
+
data = np.asarray(img)
|
|
300
|
+
# 获取波段数
|
|
301
|
+
num_bands = data.shape[-1]
|
|
302
|
+
|
|
303
|
+
# 定义输出TIFF的元数据
|
|
304
|
+
metadata = {
|
|
305
|
+
'driver': 'GTiff',
|
|
306
|
+
'count': num_bands, # 多波段
|
|
307
|
+
'dtype': data.dtype, # 使用源图像的数据类型
|
|
308
|
+
'crs': 'EPSG:4326', # Web Mercator 坐标系统
|
|
309
|
+
'transform': transform,
|
|
310
|
+
'width': data.shape[0], # 图像的宽度
|
|
311
|
+
'height': data.shape[1], # 图像的高度
|
|
312
|
+
'compress': Compression.lzw # 使用 LZW 压缩
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# 将数据写入新的 TIFF 文件
|
|
316
|
+
with rasterio.open(out_name, 'w+', **metadata) as dst:
|
|
317
|
+
for i in range(num_bands):
|
|
318
|
+
dst.write(data[:, :, i], i+1)
|
|
319
|
+
|
|
320
|
+
logger.info(f"瓦片 {z}_{x_xyz}_{y_xyz} 下载并保存成功 -> {out_name}")
|
|
321
|
+
pbar.update(1)
|
|
322
|
+
|
|
323
|
+
pbar.close()
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def from_lon_lat(
|
|
328
|
+
cls,
|
|
329
|
+
min_lat: float,
|
|
330
|
+
max_lat: float,
|
|
331
|
+
min_lon: float,
|
|
332
|
+
max_lon: float,
|
|
333
|
+
zoom: int,
|
|
334
|
+
mk: str = "",
|
|
335
|
+
tk: str = "",
|
|
336
|
+
) -> "TileDownloader":
|
|
337
|
+
"""
|
|
338
|
+
通过经纬度范围构造 TileDownloader。
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
min_lat, max_lat : float
|
|
343
|
+
纬度范围。
|
|
344
|
+
min_lon, max_lon : float
|
|
345
|
+
经度范围。
|
|
346
|
+
zoom : int
|
|
347
|
+
瓦片缩放等级。
|
|
348
|
+
mk, tk : str
|
|
349
|
+
JL1 接口的 mk / tk 参数。
|
|
350
|
+
"""
|
|
351
|
+
bbox = (min_lon, min_lat, max_lon, max_lat) # (west, south, east, north)
|
|
352
|
+
tile_list: List[Tile] = list(mercantile.tiles(*bbox, zoom))
|
|
353
|
+
return cls(tile_list=tile_list, mk=mk, tk=tk, zoom=zoom)
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def from_XYZs(
|
|
357
|
+
cls,
|
|
358
|
+
tiles: Iterable[Tile],
|
|
359
|
+
mk: str = "",
|
|
360
|
+
tk: str = "",
|
|
361
|
+
) -> "TileDownloader":
|
|
362
|
+
"""
|
|
363
|
+
通过一组 mercantile.Tile (XYZ) 直接构造 TileDownloader。
|
|
364
|
+
|
|
365
|
+
要求:所有 Tile 的 z 一致。
|
|
366
|
+
"""
|
|
367
|
+
tile_list = list(tiles)
|
|
368
|
+
if not tile_list:
|
|
369
|
+
raise ValueError("tiles 不能为空。")
|
|
370
|
+
|
|
371
|
+
z0 = tile_list[0].z
|
|
372
|
+
if any(t.z != z0 for t in tile_list):
|
|
373
|
+
raise ValueError("from_XYZs 要求所有 Tile 的 z 一致。")
|
|
374
|
+
|
|
375
|
+
return cls(tile_list=tile_list, mk=mk, tk=tk, zoom=z0)
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def from_shp(
|
|
379
|
+
cls,
|
|
380
|
+
shp_path: Union[str, Path],
|
|
381
|
+
zoom: int,
|
|
382
|
+
mk: str = "",
|
|
383
|
+
tk: str = "",
|
|
384
|
+
) -> "TileDownloader":
|
|
385
|
+
"""
|
|
386
|
+
通过一个矢量边界(shapefile)构造 TileDownloader。
|
|
387
|
+
|
|
388
|
+
简单实现:使用 shapefile 的外包矩形(bbox)来确定瓦片范围。
|
|
389
|
+
|
|
390
|
+
依赖 geopandas:
|
|
391
|
+
pip install geopandas
|
|
392
|
+
"""
|
|
393
|
+
from pathlib import Path as _Path
|
|
394
|
+
|
|
395
|
+
import geopandas as gpd # type: ignore
|
|
396
|
+
|
|
397
|
+
shp_path = _Path(shp_path)
|
|
398
|
+
gdf = gpd.read_file(shp_path)
|
|
399
|
+
|
|
400
|
+
# 假定 shapefile 的坐标系是经纬度(EPSG:4326),否则需要先 reproject
|
|
401
|
+
if gdf.crs is None:
|
|
402
|
+
raise ValueError("shapefile 没有 CRS 信息,请先确保为 EPSG:4326。")
|
|
403
|
+
if gdf.crs.to_epsg() != 4326:
|
|
404
|
+
gdf = gdf.to_crs(epsg=4326)
|
|
405
|
+
|
|
406
|
+
minx, miny, maxx, maxy = gdf.total_bounds # lon/lat
|
|
407
|
+
bbox = (float(minx), float(miny), float(maxx), float(maxy))
|
|
408
|
+
|
|
409
|
+
tile_list: List[Tile] = list(mercantile.tiles(*bbox, zoom))
|
|
410
|
+
return cls(tile_list=tile_list, mk=mk, tk=tk, zoom=zoom)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
tilekit/error_info.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
error_img_bytes = b'RIFFR\n\x00\x00WEBPVP8LE\n\x00\x00/\xff\xc0?\x10\xff%\x16L\xe6/\x9dA\xf7\xfc\xcf\xff\x04$t\x9c\x87\xf7\xff\xff\x9f\'\xed\xff\xefv\x84@ \x92\x12\x0c\x06\x82`l,\xee\x06\x07\xc3RV\xeb\x8c\xa2A\x94\x82u\x15\x8b\xc5\x85\xec\x9d\x8a\x8b\x96\xe1+\x8ae\xbb@x\x01\xc1\x08\x1a\x08\x1c\x19\x92?\xee8\xe8\xd9\xe7\xa9\xd7\xcb\x88\xfeO\x00_\xfd\xff\xd5\xff_\xfd\xff\xd5\xff\xffG\xa7\xa6\xd8.t1gg\xfc/\x05n\xd3\xaf\x8bcE\x06\xff>Q\xdbbY\x18=\x04\xc9\x13=bfm\x0b\xd5\xd6\xf4a\xeb\\m\tX\x05\xcc\xf0Tn\xbc\xfc\xc8\x14p/W\'\xd2\xdc.`\xd2\xfb2r\xdc4\xbd\xd6\x13\xe3\x9c\x1d\xd3\x88\x17\x8e)i\xef+\xd2B\x19\x97\x97\xde\xe6R}L\xbc\xb4\xde\xfct\x0f<\\\x7fr\xf6\xc3\xeex_\xb2 \xe9\xfb\x127\xa2b\xd8\x18r\xb0[6\xb2w\xae\xab\x15Q\x1e\xbe\xf9/\xf4\xcb\x99\xf6\xc9\'\xd1\xee\x1b`\xfb8p\xd4 H\xf9+z \xe9\xf6u\xa0\xba\x97\xe8\xa1\x8e,_\x1c\xa4U\x8e\x85\xfb\xc4H\x9a\xac$\xbd5\xf8t\x0f\x90(\xdb\xd8\xbc\x9e4QN\xf9\x00X\x1cb\x84\xc3\xfb8\xd8fE\xb5\xc1EQ(\xf5\xa7\x85\xf2O6\x846y\xdb\xbf\xd2.<\xdb\xc2\x86\xa9\xc1bo1Q\xf2\xc2\xb7\x88\xed\xcd\x87Hy/\xccj\x94OK\x1b\xf1\xa0N\x03\\NBdM\x979\xb8r\xea\xddzw\xdc\x06q\xbe\xc2\x7f\x91!\x1b\x11\xdc\xee#\x99\x0f\x89\x96\xdf;N>g\xe3\x1b\xa3\x1b\x98$\x06~\x13\x9b|\xa9t0\xf3bNl\x7f\x19\xb1\xfe\xc4\x8d\x8c\xf2A\x85\xb55\xb8\x07K\xbc\xd0\xe8\x1e\xf7&,]{\xfd\xd6z\xcf\xb7\x15\x97s#\xea\x07`\xef\xd3`\xeb6\xc4\xf7\xc4\xf9\xa5\xb5_\x8cQW?On*u\xfd\x0bK\xb8l\xd0\x7f;\t\xe1\xd5\xb0o\xb1\xff\xed\xea\x8b\xb7C\xe7\x9e\x9e\x1e-\x1c\xdc(\xea\x98o\xe9J<\x02k\xcb\x01\xa4\xa2\xb12\xf4\xd6\xfd\x1dw\xda\xd1XJ9\xe5\x99W\xd3\x9d\x9f\x9bu\xea\x10\xda\xcaa\xa2K\xa6\xe7\xdc\xcd@\xfb\xb9-\x0f%\xb2u\xe0\xf8\xe2\x90\x80K\x9f^\xfd\xacAp\x0f\x85\xafx\xdf\x1c\x8b:\xf5\x0c\xb6,\xe9\xcc\xa3G_\x87\x0b`\xa7ob\xe2\x94\x91\x8b\xd9\x08\xad^a\x88\x0c\xe5\xea\x13\xc8\x9d\x06L\xba\xca\xc8\xcb#\x03?\xc1\xc9^\xe9\xc4\x90\xacAp{\xdf]\xd0\x837\x87\xf6\xbb|7\t\xc4\x7f|\xbeH\xd4j\x06<\xbe\n\x18\x10Y}\x89DT\xc1s_\x1e\xfe\x02r|:T\xcd\xe9\xf3Y\x15\xe1\xb3\xa72\x83f\xc46\xf1\xe6\xca\xfa\x0f\x00\xf6-\xd1\x91Ch\xbc\xc3f\x15\xac\xee\xbf\xd7\x07\x1e\xf4\xec;\x81\xd0\xa66\x06z\xed\xd5/\x15`\xf4n\x86\x07\xab\x9f\xb2T\x90\\\x83\x12"+\x01\xf7\x82]6H\x0c\xecSQ\xfd\xb5\xbf\xe8c\x8d\x05\x90Z\xfekBh\xfb[\x8c\x1cLF\xd9\xd0\xa3R\x9f\x07\x9c\xeb\xc1\xe8\n\x8fU$\x99\xfeJBl\xd3\xfb\x97\x7f\x060\x99H\r[\x15\xee\xa3@\xd3\x1d\xc0\xe8\x1c,A\x80\x7f\xb4AzK\xa8\t\xba\xdb\x15\xff\x14\x03\x13N\x04\xd98i\x84\xdd\x8f\xe5\xe6cA\x13\x19!30V\x08\x87\xfd\x9b\x05G\xear\x00\xf6\xb6(\x86n\xc0\xebA\x13\xbc\xa8\x81\x81\xbb\xc0\x9b|\xa2\xa7+\x10\xdd\xc3K\xb7\xf3\x86|7t\xe4\xf9\xe28\xfa1\x1ar\xfd\x9b8\xb4f\x80\xf7y\x14Nj\x85G{\xf9\xcbBy\x1c M\x96\x135W\x02[\xfd\x97`\xec\x1a\xcc\xe5B4\xe2*eEC\\\xf9B\xa4I\x8b\xea\xc9\x05-\xa53\xbb\x1e\x04\xda\xadp\xe6\x1d\xc4i\x10Z\xcb\xfc\xea\xe3\xac\x81\xa1\x1f\xaf\x8e\x80\xceY\t\xda\x05\x07\xba\xa5\xb5\xbbf@\xd7\xf4\x16\x01\xce\xa8*\x88\x07c\xf0\xf0\x95\xa57~=\x94OJ\xd8\r\x00\xe6\xa5gI"#\x15\x9cQ\xd9\xb0\'\xd2w\x80\xe1r\x88\xf3\xfd\xc8\x86f\x04V{\xfa\xbd\xcf\x17\xa7\x90\n\xab\x14\xcey\xa0xF\x82\xaa\xa1\x8d\x04V_\xe1\x9drDO\xfe\x02\xec\x9d\x9e\x0fn\x05\xb6F\xe2A\xe3\xc9\x02\xa3l\x17\x9d\xc4\xea\xe5\xe1|\tN~\xd0\xc2\x96\xe3Q\x9d\x8d\x00\xb3\x07\x80\xeb.\xa0\xee\xa9\xe0\xe4\x07z3Qj=\x0e\x9469\x11\xe8/\x03\xf6z\x01\xa3Qp\x12\x03\xbbQ\xaf\x98\x92\x14\xf4\xded?/\x9c`\xe8\xfa\x0f"\xdc\xf8H\xc5X\xbd\xbc\x9e\xafb_I[\x8d\x9b\xfa\x89<\xbf+^l\x12\x87\xb4\x8a\xad\xa14\xb0\xd4\xcb}\x99U\xc3*\x0cM\xb7\x19\xd6\xadH\x9b\x11W}>\xb0\xff3\xaa\xdd\xcd\x19\x7f\x06]; A\xceT)YO\xdf\xedAlw\xad\xa7\xc1\x89\t\xb5\x9d\xebr\x9d\x05\xe5\xfd^\x15\xcb\x1d\x88\x12\x1c\xdcMp\xf9\x11\x06\x05\xcf\xee\xa0\x9e\x12\xb2)D\xd7`\x05\xb2\x82&\xd8\xf3\xd8oP\x1c\xf2oR\xd1\x9e\xf0\xb9\x04G\x9b\x01\x9c\x08\xdd\x8b\x87\x89\x9a\x9c\xe7\xabw^_U0~\x19\x88\xbd\xe8\x99\xb9\x1e2\x8b\xcd\x91e=\xb0{\xf4\x9d\xa4\xb9\x1fY\xbaj\xa0xI\xa7(^\x8cI\xa8\xf2\x8d\x17K\xecDl\xa5w\xbf\x00H6\xe7\xec\\\xa9\x0eH\xf0;\x15\xd2\x8c[~v\x08\xb1\x95\xf6P\x9c\xcd\x89\x0fZ\x88\xbd\xb2\xf4\xe6\xa8\x06H\xba\xed\x9f\x9e\xd5\x00\x94\x84v#\xb6\xd1%\xd3\xef\xa3/\x8c\xa3\xf58@\xf74\x17`[k\xe0\xe1\xae\x98\xc5\xa3\x8a\xe8\x99\x1dB\xa3\xaf\xf4N\x14k\x88\xfd|\x98\x8bS\x12\xaa{\x9e\xc8Mi\xc0\xaf\xaf\x15h\x10\xd8\xc4\x9b+\x81*Z\x9d\x1a~{F\xac\xaf@\x91\xf3\xc2\xff\xc7f\x80}}\x91\x1c\x85\xd0\x1e|\xb8k\xe08\xdf\xbf~\x93\x91 \xef\xa4jD\xd1yI\x0fHG\x06\xfd5\x95\xfb\x84\x07\x98\xc8c\xeb\xf3\x91@Fm7\tr&\xeaQ\xc7\xdf.^\xde\x84\xc8\x9e\xfe}#\xaf\xed\x92\xe7(\x19Rrp+\xb5}*\xba\xb2\xf9\xff\x9e\x8bAl\x0f.\xeb v\x1bX"o;l6\x80\xceFR\xc2;@{\xfd\xd3\xc4O\x1a\x84w\xfadB\x95\xaf\x03\x9csY\\\x1b\x8e\xbd0\xae\xb3\xc9\x9b\xe9r\x01\xf5Y\x88l\xd4\x06U\x1f\xe5\x81\x83\x80F\x07i\xeb+\x13\xc5\x1a.%\xb3\xf3\xcb&D\xb7\xe9]\x8d]\x83\xad+\x14\xb1\xa3\x9e\\\xbb\xe6\x1d\x06t\xa0\xffM6\t\x8f.\xab\xeeC\xdc\x19\xb9!5x1F\xad\xba{\xa79d\x93*\xc7\xc9\xf0\x0f\xecBd\x8dNT%\xbeI\x04\xcf\xa2\xbf3[B\xbd\xbdg\xe0\xcdw\x90\x86\xc8\xa6\xd4\xc9_\xec*$I\xc0x\xe9\xf6\x86\xb5\xa9s:\x95\xca\xf5?\xb4\x88\xad\xcd\x15|\x90Q\xd7\xab\xb0\xb6\x85\n\x80\xbeS\x10_>;\x0e\x98\xff\x9a\xd9\x87\xd8f\xf6\xca\xf5\x16H\t\xdb@\xebm\xa9\x19\x05i\xa1\x08@J\x85\x14\x7f\xbd\x0e\x91\x95\xf2\x87\x97\xab\x8d(]]@\x0c\xba\xcf\x07\xb0\x7f1)Hm?\x8a\x15\xb1\x1d\xf4V\xe8Q\xcf\x0f\xa7\xa0\xbc\xdeGc\x0f\x80\xcd\x15h\xb7 \xba;\xb4\xa8\xef|\x18h5\xab|\xb3\xb6\xa38\x13\x0e\x0f\xf8\xef\x9a\x11\xd78\x87\x1a\xbaj\x1dp\xf8?\xfe\xdbI\x00\x12\xd0=_{\xban\xd6s)\x1e\x915\xac\xd9\x81(\x90\xa6J\xa5\xe2\x89\xa5+\x06\x94\xff8 \xde\xeft\xd6\xde\xfb^Bd\r\xe9\xdc\xed%\xa3\xdb\r8\xbc3\xb3N\x1d\xea\x97{\xe0\x87\xb7\x08nI\x0bU\x0f1\x87\x07\xe5\xdad@\xfb\xa1^\xc3\xc6\x07}p\xde%:\x17\xdc\x14x\x8a\xc7\xd7\xdf&\xa0\xfa\xcb\x1b6\xd66y\x00\xad\xe8\x94\xbc\xc4\x12\xf1\\\xdc\x1dJQ\xd3/\xe7\xaa\xe9+\xbd\xefR\x11[m4p|\x04}\xc4\x04=\xf7!\xb96\x0b~\x7f\xa1H\xac^\x19\xfeQBh\xf5\x95\xde\xa3@\xd9\x8a\xde\x18\xd1\xc1>\xf9@g\xc0\xb5\r\x8c\x01;\xa9\rr\xaf\x1d\x91\xbd\xb19\xb1z\xb9\xd7\x0e\xd0\x12\xa9L\xf0\x03v\x7f\xa0.\x05\xe5\xf5\xcf/\x83]6\x84V\xfa\xd2\x1d\x90+\xcd\xb1\xc9;c\x19u{\xb5\tR\xde\x90\xcf\xe5\x8bE\xdd\xe9LAl3\xbaC\xb5\x19\xe7\x9eG\x02k+V|\xd6\xe93\xd2\xe8By\x9c\xf4\xf6\xe2\x06\xa2{h\xe0\xf3L3\xfa\n\xcf\x9f\x12@6%\xef\xa5\xddZ\xe0\x84G+D\x7f{.ZV\xb2\xaa\x17\x9b\xd3\x0e\xf4\x9dA\x19\xed-@\x19\xe59)D\xdfj\xe9\xf8\xb2X\x93T02\x94\x97\xa2UP9\xaaB\x99Kh.[\x151\xc0e\xaf\x9c\xe5\x98\xfa\xdb\xfem\xc7\xfc\xf6\xa8\xa3Z\xd0\xaf\x1cT\xd1 \xb4#\x0e\xa4\xa2\xb1z(\xfd\xbc\xbd.\xd4i\xfb\xfe\xaf\xa9\xd3\x86\xb2\xf9\x91d\xe0\x8f~\x15q\xb5H@kc\xc9\xb4\xa7F6R\xb4\x1bK\xf0\xcc?\xa3\x05\xc6\xeb\x9f\xdc\x07Pn\x96S\x84F\x1bJ\x05*"\x93\'\xb5\xb8\xabP\xba<\x07\xcd\xf7\xfc\xae}\x93\xb5\x06\x05\t\x88\xed\xeb\x1f\x92\xaeJen\t\xc8\xfd\xacS\xec\x90\xbbV\xeb-pt}\xe9\xa4Bt;\xde\xc9\xabE\xd7Z\x01\xa4I\'\xb0\xff/\xdfD7\xa4\xf4\xbc;\xbc?[pr\xd2\xe1\xfeZ\xbd\xa5|\xf4Fu\xeaw\xc0\x99i\x89\x13\x1f\xca\xe3Z+c\xaey\xcb\xa2\x10X\xbd\x01\xcd\xd1W\xa1kp\xfe\x15\xc4\xf9\xb2\x92\xce\x04zl\xc4|\xca%F\x0b\xafj\xa6\xeb\xbfAhGO8g_\x1f-k\x06\xdd\xa7l\xa8\x1b\x00s{\xc8Iu/\xc0\xa9\x88\xdb\x86\xc0J\xb5\xf1\xcc\x86\x9f\xe7\xe9\xc8<\x0f\\sg\xf6~\x923\x00\xdb6\x92\xc3& \xbd\x10\xa15Ft\xd2Z\x16O\xaeR\xba\x070\xacy.\xc4\xde\x7f\x80\xfa\xea\x1e\xc4w{\x98}sP\xb0\xa4;\xbb|7\x16\xee>\x86\x94`\xaa\xc2P\xeb5\t\xd0\xa1/\xa6\x9b\x8d\x86\xb3\xd2T)\xe6\xfeN0\x07\xd3\xa0\xbb\x01\xd0\xcd>IF\x80\x7f\x8f\xdcJH;<\x84c\x0e\x88\x05\xda\x9b\xb7w\xc8r"\xa0E\x88\xdfv\xf8\xe3)k\xc3\xb1n\xc0~[\x0f\xd6\xb0\xffNRy\x12\xa2\xac\x1bK\x18\xbcL\xe3\xe5\xe4\x89b\xb0\x8ez\xf3\xa1\xe7\x0e\x82}d1&\xcd\xacq\x15J\x14\xa5]\n\x9cg\xefU\xd1\x92\xde\x14\x1a\x8eK?\xcf\x0ei\xda\x7f\'=\x05\x01\x8f7X\xc2\xdf\xa2\xb5s\xb3\x1f1?\xe6\xe6\xcf\x0eHoY+\x14\xb4\x82q\xbe\x0b\x95=\x96\xeb-\x08z\xd6\x0c\xfc:^mD\xd8\xf7.!\xfa\xf1\xc2\xf7\xd5\xff_\xfd\xff\xd5\xff_\xfd\xff?v\x01\x00'
|
|
2
|
+
|
|
3
|
+
error_img_bytes2 = b'RIFFL\r\x00\x00WEBPVP8L@\r\x00\x00/\xff\xc0?\x10\xff%\x16L\xe6/\x9dA\xf7\xfc\xcf\xff\x04$t\x9c\x07\xb6k\xdb\xa6\'\xcd\xb6_\xc9\x9d\x04\x02\x81@4\x10\x05"Q\x14\x8d\xcc \xa2\x82\x03\x8a\xa2\x11\x04\xcb\xa0(\x15EhAj\x9ce\xd6\xd4\x08\n\x86\xc1\x08\x8aEQ\x94\xd6\xa9(8\xc4\xa1\xf2\xe3\xee\xf4I\xde\xe9\xbe\xd7\xfb\xc9\x88\xfeO\x00?\xfc\xff\xc3\xff?\xfc\xff\xc3\xff?\xfc\xaf\x063\x7f7\xab9\xb1\xc5??\xd3\xac\xe2\xcaf\xe6*\xf5\x1b\x17\x8d\xea,Q\x02\xafGc\xcdc\xbcN\x95\x89g\xe5\xc4n\\4:_IE\xaf$5F\xd5\xec\x95@\xe2x\x1d\xe3\xe5\xe2Q\xb9\x1as\x0e.\r\xad\xa4\xe8\xb5T<I\xd9\x13\xa1\xa2\x8c\x80^\x8f\xf1\xe5\xe9\xf6{ \x1e\x95K\x8b6\xed\x8bb\xf5d\xfa\x90\x10u\xf4\xcd.\xd0`\xfa\xb4\xa9jp\xefSq\xa5\x81\x9cD\xf5\xc4\xf5\xa9EO:r\x93\x7f\xe9\xe1.\xdd\x8b\xe2\xfd\x83\xf7\xadX\nTR\xd2\xc5\x0fKNL\xf5\x12\xd4}\x9a\xf8\'\x06\xcavn\xba\xe7>\x83eA\xabz6\x1a\xe0\xb7\xc5V\xcb\xf0\xd9\xb6\x85+&\xc8Kg\xfeN\x91\x06r\x9f\xda\xde\x9bL\x1f\xedjG\xf7(\xf0{Qz4\xf6\xa1%\xf7r,y\x00\x9e\xbb\x03/\x9bh\xe9\xa6\xf3\xc5\xdb\x0b\x1a\x15\x13\x93\x84\xdcq\xb2\x9ch\xef\xfc\xf1\xa9\xc3\xc9\x97?\xbd\xd4\x005\xc3\xac\xbd\xa2{\xbe\x83\x88\n\x07j\xf6\x97\xd3\xa2$\x82\xa0\xa2\xcc@C`\xb1\xd52\x97\x0f\xe4<\x05\xda\x1eJ\xa8Y\xd3nh\xb8?;\xb1R\x16\xb7\x156\x8d\xbcy\xed\x82\xcb\r\xc0\xae7`ykG\xd5\xa6\xccv\xd4\xbf\x99\xcb\xc6\x08\xd6s\x1f?\x99\xdd\xcf\\:\xd7c]\xcd\xfc:\xe0\xb8\x0f0\xa0Z5[\x04\xc4\x9f[\x1a/x\xb7w\xea&\xac\xeeH\xe8o6\x0b\xd0\xbd\xd9\xe2J\x06#g\x7fA\xd5j\xe6\xd7\xc3uw\xd5\xd4\xc1\x85\x99\xcc\x17\xa5\x19@\xda\xa2\x11\xe0\xc8]\xb0\x9e\x9b\x85\x08u\xc3\xf9\x16r"\xa3\x1a\x96\x86\xb6\xfc]8\xb3P\x080T\'\x8b:l\xbf\x1c\xb8jG\xf5\xe6\xbf\x9c\\rU\xb7\x1d\xf5m_xZ2o\x95\xe5\xbe\x8e\x00\x91\xe9}\xdf\xb6\x9c\x90M*b\xcfQ\xa4\xf2\\bg\x97N\x15\xfb\xd0\x1dZ\x1a;8g\x9bh\x95\xc5|\x0b\x0c\xf5\xbf}R\x1fE\xc8k\xbb\x9f\xc7\xaa\x07\xe7b\xfd\xab\x89,(\xf2\x1e\xd4\xce\xdb\xff\xba\xda\xde\xbf\xea\xed\xd7\x1a\x83\xac\xd1\x1b\xe5,O%\xf4\xbca\xcf\xf2\xadMj\xc1*1>\x93\rf3\xd5=t\xbab)\xf8\xfe\xa8\xf5\xdb\xf0\xbeX`\xa8\x82p&\x0c5\xae\xbbI\xf4\xb3\x08\x95\xe0\xdf\xcb\xe6\xd7\x92\xcd\x1d\xa8\xc2\xf1\x9a*7-c\x1f.\xf0\xb0h\xe7\x83g\xcbhZ\x1b\x06c\xed\x86q-}\xe7\xaf\xb5\xd5(>i\xf2r\xb9\x8d\x92\x19!\x9eL~\xe9]\x03\xe2]\xd5\xceq\x0c\xd2\xb3\x85\xf8]#\x90-\x08\xa3\xf9\xd4\\\xa9\xb6\xa6\xc1}\xdd\x8b\xe4\x97\x14_n\xed\xe0y\xc4l\xe3\xad\xc09\x1bT\x9b\xd9\xdc\xff1P\x1d\xbb\xfa\xda\xd9\x16\xcd\xdcz\xc2\xdb\\\xe8]I\xe4\x8b\x95\x0c\xd8\x0f\xfa\x93\x95^\xf0\xe2\xd9\xc5S\x96?\x92\x91\xfe\xb1\x00+\xbe\x0e{\xdd\xc7m\xef\x8c\xa5\x85\xa1\x99\x1ak\x012.\xc0\xc9\xc2\xa8\xcb\xafK\xb5(\x7f\xb3\x06<\xb5Fp_"\xe9\xb3\x00\xd6N\x13\xf7)\x81\x93\xa9\x84\xbc\xbcm\xa4\xadQ\x86\xaf\xc0\x1d\xf7\xc4[8b\x8em\xaaQd\xa6V\xcd\x7f\xe0{\x7f\xb3F\xa0\x99\xd2\xdb\x02\x96\x8aI\x80\x9d\xbd\xb0\x8ap\xb6\x0f\xe4\xd2\\"\xcb\x9e|\xb4\n+\xb8\xee\x0f\x14\xa0\xc8\xa5\x97\xbbd\xe6\xa6\x1a\xc0V\xde\x06\x91K\x82\xbe_g\\\xb2\xcec\x84\xb3_\x8b\x19x\xf0k\x86\x01\xf6Z\xfdFpt\xf6?\x88DS\xbaBi\xad\xd0C\xed\x04$\xfe\x16x\xf8\xd6\x80<e\xd9\x8a\x0f\xb0\xf2\xc8\xa2A\xb6\'3,\xe3\x06$\xb0\xdf\xca;q\xeb:PV\xafK\xefHf\xff\xb1C\xfe#F\xa5\xd59\xdfh2\x05vw\x7f\xe9K\x13s\x15An\xee\xaf\xf1\x82\xe5M%\xff\x85c\xc6D7\xfa\x87\xa9\x80\x01\x90&\xba3\xc1tr\xd6\xa5Cyg\x0e\xec\xcb|\xf1\xe5\xdc\n\xa0n&\xc8\x9d\xb2\xa2<\xb4c\xfd\x84W,\x93\x8dj\x9c\x8d\xb8\x1b\xc1\xdc\xec3\x80\xe9\xd6:,m#;\x04\xca:\xfff\x14 \xb6\xdd[\xf4>Gn\nd\xc9\xfc;\xc1\xe0\x9d\x8e\n\x8b\xce5}V\xe6)\xff\xe3@\xc2eas?\xdc\x04\x08\xaf\x1dV\xe7\xa2\xac5;\xa7\x16N\x1aa\xf9\xcc\xebz\xa3u\xc9\xba+\r\xb8\xd0-\x9b\xda\x86yr4\x8a\xd0\xaf\x1b\xebG\x0f\xf4o\x90y\xcb\x0f\x14\xc2\xd5k\x99\x93\x12\x80\x06\xc5\xad\xaf\x98{Y\x13\t J%\xe0\xdd\x9e\xb6\xaf>\'\x8e\x8f\x06\xe0I!N\xb7\x8e\xd0\xa3?\x0e\x97\x88\xf4\xbb\x04o\xb6l\xd9k\x86\xfa}\x88\xedN\x14\xf6\xb6\x04\xe9\xcf\x99}\x12@\xdaJ\x82\xdei\xc4\xd6\xfdO\n\xaf\x0b\x81\xbf\xf2\x08o\xe6\xc4\x06#\xa3\xab\x85e\xd7\xa5L\xe8\xaao\xd5\x00F\xbf\xeb\xde\x89\x18\xa5\xb5\xb0\x16\x87\x00\xc8\x19\xfa\\\x1e\xec\xccY \x01\x06\xab\x01\x9b!\x0c\x12\xe0\xad:}\xfb\xf9\x8b\xdb\xa3\xbd\x15v\x81q~\'\x8em\x18\x8fL\x1f\x8e@i[\xbe\x1b\x00D\xc9\xe4R_<\xc1\xfb\xdb\x90\x9b\x17\x8a\tk\xc6\xef5\xe0\xec\x81\x83n\x01D\xec\x00,\xa4\rF\xd5\xfb\xcb\xb4(j\x11\r\xec\x99!c\xbdt`\xf6u\xfd\xa5\xdb\xfc\xeb\xecn\xc0\xd4\xf2\xb6G\x1bRZ\xa5\xd82~\xc6\n\x98\xe2\xa8;/\x06\x88=18\x92\x04\xa4\x0fEa\x13(ji\xffl/0Y\xcd\xad\xc3\x17f]:\x12\xbe\xae\t\x16\xfb=\x05\x90Ng\x10\xba\xe1\xe1\xd4\xcf\xf7a#\x10\xdd\xd3 \xb8\xd2\xe3\xc9&\xb3\x15"\x86\xa2Q\xd8\xc6\xfa\xd73\r\xdfl\xac]\x88\xe0e\xbeI\x00t\xf5\x04;\xfbX\x10\xde\x88\x9f\xc6\xab\r\x8c\x94\xde\xf6gb\xf0e\xe1\xb82\xfd\x13 \xee\x00Z\x94\xb5\xa9y\xf1\xde6\xc1M7\xd2V\xccKI\x04u|\xb3\xc9R\xff\xc9!\xac\xb1M\xa3\xc3\x871TNU\xe8\x13o\x80Xs\xed\xac\xcdU\nX|(\xeb\xd8\xa6(\x96{3\x012\x02f\xe0\xe8RC0\x06\xdc\xb2#\xfd\x84\xeet\xc0\xfe"!.\xf7\xdf\xde\xb9\xbf:\x99n\x07$\x99a\xd0\x88\xb1\xfa\xce*\x85\x95rw~\x9f \xb8\xff\x14H\xafn\xfc\xad\x0f\x96\x190\x03H!\xe9[=&\x80e\xbf\xde(\xd8\x87q\xfb\x95\xc9\xa2.\xe4\xce+\x90Q\xacAq\xef\xf1GCt\xc3#\x03[\x16"\xd9\xfc\x97n\xa6"\x98\xee\xc3\x16Y\xc8\xe9w\x0f\x10\xd4WD\xf1\t\xb04\x19\xf0&\x00\t\xc3&\x94xV\x11`i\xfd\xe0\xfb\xb3\n1S\x8b\xd6I\xf9s\r`<\xfc\xcc\x1f\x13\x86\x15]=\xb1\xd1W\x83\x14\x9ef,&\xe9|_\xfbA\xb2:\xc0<\x96\x88\x926\x96\xca\xac\xc9T>\x13\x14~\xf5\xa4\xe3\xfaS\xcb\xdeW\x12 \xbd\xdc\t\xc4x\xb7\tB^v\xee\xda\x9d\x08\x8e\xe6\x05a\xac\xb8\xbd\xfa\x86\x1d\xbd_\xc3\x98\x9e\xd8d\x14\xf5\xba\xd7\xdd\x86U\x9d\x9f\xeb1\xcc\xef\xc0\xf8\xbe\x10t\xafw#\xbd(\x07\xa8\x99"\xcc\x9b\xfa\xd2\xa2N\xb0\xfawr\xb4\xb2\xddG#\x1d]8\xfb\xcf\x197\xf8\xa3Q\xdc\xe6\xc73\x01\xb7\r8>\x05-c@\xc34\xfc\xf4D\x00\x91\x0b\xebB\x8b8B\xd0zg\xc7\xccfv\x0f\xb9\x04h\xfd:\xbc\x83\xa7\xe3\x89h\x8cGi\x8b\xa2\xf1\xaf\xcdq\xc8M\x1f\xf3\x89\xff\xe247/\x8e\xc7\x10\xf1>\x1f \x96\xd0\xa3\xfa\x838^\xb5\xda=\x88r\xefQ_$T\x1d \xc1\x88\xe2v\xfb\x8f\xda\xed\x8f^7}K\x06r%h\xbb\x03\x17\xe6\x02\xde\x8d\x80\xf6\xe9!Y\xc8uu"\xa2\xd7X\x0cXW\xd0a\xcb\xf0\xee\x15\x18\x01\x83_\xa0\xc0\xe3\\\x83\x1f\xad\xe5\x127:4;\xa6\x96\xf6\xc0\xf2\xafkYyu\x15@\xc9\xf4=Sh\xc6\xfac\xb8\x9a\xac/Fv"\xdfq\xb6\xeb\xd29\\\x97wD\x01\x8d9J\x0c\xd0\x01\x14~x>\xeb\xaa\x99\x02.\xf7\xf3\xaf9;4\x84\x1a\xd7<<m\xc6vn\xa6\x8d\xa0\xeb\xde\x9d\x89c(\x8e\x98\x92\xceA=\x02%]\x9d*\x93d\x8eh@\x1fh\x16D,l\x04\xfb\xb7\x95\xff\x12\xc6M\xd3\xcd\x14\x9f\x88\xeaM\x8f\xf4\xc1\xeaLH\xb7\x99\x8b\xd9\xdc\x0c`@a\x8fUB\xe1\xe8\x05\xc8\x1a\\:\x06p\xeb\x14\xd0\xd2\x03\xdc\xb8\x10\xae\xfc\x04\x11\xe1\xc3\xfe\xd2\x00\x9c\xaf\xf0v\xda\x01jw!&%\x94u\xbc\x168w\xb1\xf4\x8f\xf93\x9f\xe3\xb9\xd6T6o\x00j\x06\x80\xc4\x80\t\x9c_\xe2\xc3`\xbc\xb4\xeb\xc6a\x034\x8dt\x9e\xaaF\x14O~\xcfE\xae\x990\x88MO\x8d\xcaJ\x13\xb0\x03\xd5Ks\x95\x06\x86\x9a\x01\xa6+\x81\xf5\xaf\x00F\xcb\x80\x9f\x13B\xd3W\xce7\xeaw\xed\x80\xd8[H~\xed\xb5\x13v\xdf\xf5\xf5\xb2\xaaz\xe2\x8f\x9aP\xd8\x93%\xb1u\x1a\xd7=\r\x90\xbf\x18\t\x94\xceitb}\x00\xd0M\xef%\xac5\x9e\xa2L\xbf\xa7T*\x96\xe8\xc8\xdc?[\x06\x9a!\xdd\xa5\xf6Dh\xd3\xa1\xc0/L\x7f\n\xec\xaa\xefA>]\rh\xff\xdc\xe5\xaa\xcf}\x85\xa6fn02\xb4\x95\xbfdi\x13\xa3\x1b\xfa\'W\xb8\x06\x0eH$\xbe-\x8f\x19\x8b20\x00\xe9\xfd\xbf\xa1\xb83R\xa09p\xd5^5\xd5\xd8fM\x05\xf6\xcd\t\xa0\xea\xe1\x83\xe2:?\x94g\x13F\xe1l\x1e\x14%\xeb\xa3\xae\xf8K\x8c\xf9@g2-\x7f\xac0{\x01\xa2\x94\x94>\n\xb1\xe5\xee\xf7f\xa8x$0\xbc-L,\xff\xec\xb1#\xfd\xbd\x050,\xcc\x08O\x0b\xe1\xac\x12\x04\x8do\xe9\xcf\x89\xf9\xf9\xda\xf8rp\\\xca\xb83LM\x99LQ\x8f\x94\x97=\x9euUv\x82\xfe\xef\xcd\xf0\x8b\x0f\x12\xbb\xbeV\xd34\x04\xe0+#-9$;p1I\x96\'\xca\xd3\xacm\x9d\xe9\xd2/m \xa6.G\xb87vE(\xab\xd3ff\xbe\xdc\xdf\x11A\xe6\x11\xa0alm\xff\xfb@:\xb0f\r\xcb\xbeZ\x81(-\xa1\x8b\x0b7V\xe0*\x85\x82\xb1\xf1bx\x9c\x1aY\xd3]8l\xc2\xee\x02\xdb1\x94u\xe4R\x0c\xefv\xd0\xdd\xc4\xbeu@\xf4\x87\xf9c\xd1\xed\xd7\t\xfe6\x8b0\x1bX?\x96\xbd\xa6\x1dR\x8c1>\xa8\xf4\\\xde \x95\x8f\x1eG\x91\xdb\x96\xb4\x8e\xbf\x05E\x0b\x11\x95\xef\xdb\x0c\xd0\xea\x05\xeb\x97$Y\xe4\xa9\x85\xc4p\\<l\x15\xc3v\x84A{\x07\xf9\x99l\x0cc\xc6\xda\xde\xcdbJ\x999\xbf[\x1b;#\x0f\x88\xc7\x87I\x18\xba\x06\x96\xcfv\xe8\xba\x08\xe8f\x87\x93\tg\xe4\x8e\xdet\xcbh2\xe0\x0fb\xbdI\xe6q\x9f\x13\xed\xcf\x1ee\xd6\xb0\xd4\x1e\x97\xbaq\x8a\xb2\x17\x02\x8c\xc0\xa5\xab6\xf7\xa7\xcf\x16\xc0@\xc8q\xb2\xa0\x16\xdfj\x18\x0fB\xe7\xb6\x8b\xa6\xdb@\n\xca|\xa2\xf7\x93\x89\x03}l_\x8a\'\xad5\x12\x92\xbf~:\xbf\xb2>\x910\xae\xee|\xe6\x08\xb2!\x1d\xe2\xbc\xf5b4\xd8\xaaO\x0e~ME\xa9K\x7f,\xf35\xd2v\xd22S\x0e\xab\xa6^m\x01\xcfo\x845\xdb\xe3\xb6\xd9\xbb\x00\xeb\xd0\x85[\x80\xa8\x1c\xf0\x05\x13\x17@\xa0\xe4\x0b\xde\x1aRV\x88\x8b[\xa10\xb9\xe1\xcbO\xa4\xb7\x86A\x14\xdfn6c\xa8\xbe/\x95Yu\xf1\xb4;\x01\xb6\xbe\x18\xdeoA\x15>\xdc\x13\xb9C\xec\xf9\xcb/]l\xc5\x9eB8u\xf7\xeb\x8c\x98\x1aom\x17\x1c+\x04\xac\xd7e\xedy\xa6J\xcfD\x9d\x1a0\x1b\xad_\xd7\xa0\xcb\xa7q\x84p\xf7\xda\xad\xed\x9e\\\x88o\xe9\x15\x00W\xec\xa5f\xed\xa4@-\x16\xfb\xe9\xec\x05k\xc7\xa7}a\x11\x16H\x9b\xedN\x83\xa4\xf3\x9eL\x82\xda\xaf\xdc\x96\x8eU\xa3\x1a\x0b\x1f\x93\xfa\xad\xa23\xd0\xb9\x8a0j\xcbF]\xc0\x80\x95\xb5=W\x1d\x8eV\xd2\xfb\x8a\x81\xdbu\x8e;B=d\xbe\x80\x86\x993VB\xf6`\xa8\x1e\xff)\x02\xc099\xd8\x91\x08\xe2\xe1\xc0Y\x1b\xc0v\xa3%\x01\xf5\x98\xf6\x810\x8f5\x8d\xed\x950\xc8\xb0\x1a+\xb7kv\xde\xf14\xa1J\xe3\xc2c=\xf3\xa1D`n\x9eM\x93\xc1\xf2\'\xa35\x91\x9a\t\x83*\tk\xd2\xd5\xdf3\xeec;\xdb\xbb!\xb9G\x16\xdd02#\x01\x95\x87\xd4\x8fMOl\x1e\x8eT\xc4|\xe7E;b\xfb[\x1b\x98oo\x13^\x00C\xa1\xfa\xa9j\xea\x18\xca\x07\xb0~=\x84\xce5T\x9d\xe3F\xae\x1d\x93\xa9\xdddp\xf4}\xca$hi\x9d\xef\xc8\xd0\x1e-\xf6\x17fY\xd3\x1e54\\|\xe3\x8a\xfdx\x16,\x8b\x81\xc1\x98\xc6&p\xf6\x9d\xafh\x96\x95\xa2~\xc5\xd6\x07\x0f\xac\x89@\xf2%o\x12\t70\xdd*\xf4\xb6\xc4\xc3]=\xaa\xd8>\xd6\x18\xe3\x93\x86Y\x7f\xad\'\rH\xc9\x82\xf6\x0bQ \n\x1e\x19\xd5\x91\xde\x00Z&\x06\xdd60\x12\xd4z\x1dXVkD5kJG\xfe2C\xd2\xf9\xb9\xa4 \\\xb1\xa3\xaa]\xe3u\xc9\x1e\xd6\xf5\\MM\xbb\x10,\xd9\xa9\xae\xb2%*\xbc\xde\x8e\x04`\xc0\x12D}\x8b\x91\x99D\x80M\xe3\xad*\xad\xb6vG-\x9a\xd2\xdb\r\xd1\xa5\xea,eT\xa3\x19=4T\xa1G\xadG&\xc0\xc1\xf3\x1a\xd4\xbd>\x97\x1f\xfe\xff\xe1\xff\x1f\xfe\xff\xe1\xff\x1f\xfe\xff\xff^'
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tilekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: tilekit
|
|
5
|
+
Author: ZPZ
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: mercantile>=1.2.1
|
|
9
|
+
Requires-Dist: numpy>=2.3.5
|
|
10
|
+
Requires-Dist: pillow>=12.0.0
|
|
11
|
+
Requires-Dist: rasterio>=1.4.3
|
|
12
|
+
Requires-Dist: requests>=2.32.5
|
|
13
|
+
Requires-Dist: tqdm>=4.67.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# tilekit
|
|
17
|
+
|
|
18
|
+
A lightweight toolkit for downloading **JL1 (Jilin-1)** XYZ/TMS tiles and saving them as **georeferenced GeoTIFFs**.
|
|
19
|
+
|
|
20
|
+
`tilekit` wraps the low-level HTTP requests, TMS/XYZ conversion, logging, and raster writing into a simple, high-level API based on `mercantile`, `rasterio`, and `Pillow`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- 🗺 **Bounding box download**
|
|
27
|
+
Construct tile lists from a lon/lat bounding box and zoom level.
|
|
28
|
+
|
|
29
|
+
- 📐 **Shapefile-based download**
|
|
30
|
+
Use a polygon boundary (shapefile, EPSG:4326) to determine the tile coverage.
|
|
31
|
+
|
|
32
|
+
- 🔁 **XYZ → TMS conversion**
|
|
33
|
+
Handles the JL1 API’s TMS-style Y coordinate internally, while filenames remain in standard XYZ.
|
|
34
|
+
|
|
35
|
+
- ⏸ **Resume support / precheck**
|
|
36
|
+
Skips tiles that already exist on disk (`z/x/y.tif`) to avoid duplicate downloads.
|
|
37
|
+
|
|
38
|
+
- 🧍 **Human-like throttling**
|
|
39
|
+
Randomized delays between requests to mimic human behavior and reduce the risk of rate limiting.
|
|
40
|
+
|
|
41
|
+
- 🧾 **Structured logging**
|
|
42
|
+
Logs progress and errors both to console and to `download.log` in the output folder.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
Once published on PyPI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install tilekit
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
## Getting Started
|
|
57
|
+
|
|
58
|
+
### 1. Download tiles by lon/lat bounding box
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
from tilekit.downloader import TileDownloader
|
|
62
|
+
|
|
63
|
+
min_lon = 114.98
|
|
64
|
+
max_lon = 117.20
|
|
65
|
+
min_lat = 38.69
|
|
66
|
+
max_lat = 41.18
|
|
67
|
+
zoom = 18
|
|
68
|
+
|
|
69
|
+
# JL1 API keys (replace with your own)
|
|
70
|
+
mk = "your_mk_token_here"
|
|
71
|
+
tk = "your_tk_token_here"
|
|
72
|
+
|
|
73
|
+
downloader = TileDownloader.from_lon_lat(
|
|
74
|
+
min_lat=min_lat,
|
|
75
|
+
max_lat=max_lat,
|
|
76
|
+
min_lon=min_lon,
|
|
77
|
+
max_lon=max_lon,
|
|
78
|
+
zoom=zoom,
|
|
79
|
+
mk=mk,
|
|
80
|
+
tk=tk,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print("Total tiles to download:", len(downloader.tile_list))
|
|
84
|
+
|
|
85
|
+
downloader.download_tiles(
|
|
86
|
+
output_folder=r"F:\xxx\JL1Tiles",
|
|
87
|
+
verbose_debug=False,
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This will:
|
|
92
|
+
|
|
93
|
+
- Compute all tiles covering the bounding box at zoom level 18
|
|
94
|
+
- Convert XYZ Y to JL1’s TMS convention internally
|
|
95
|
+
- Download each tile and save it as `z/x/y.tif` with proper georeferencing
|
|
96
|
+
|
|
97
|
+
------
|
|
98
|
+
|
|
99
|
+
### 2. Download tiles based on a shapefile boundary
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
from tilekit.downloader import TileDownloader
|
|
103
|
+
|
|
104
|
+
zoom = 18
|
|
105
|
+
mk = "your_mk_token_here"
|
|
106
|
+
tk = "your_tk_token_here"
|
|
107
|
+
|
|
108
|
+
# Shapefile should be in EPSG:4326 (lon/lat)
|
|
109
|
+
shp_path = r"beijing.shp"
|
|
110
|
+
|
|
111
|
+
downloader = TileDownloader.from_shp(
|
|
112
|
+
shp_path=shp_path,
|
|
113
|
+
zoom=zoom,
|
|
114
|
+
mk=mk,
|
|
115
|
+
tk=tk,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
print("Total tiles to download:", len(downloader.tile_list))
|
|
119
|
+
|
|
120
|
+
downloader.download_tiles(
|
|
121
|
+
output_folder=r"F:\xxx\JL1Tiles",
|
|
122
|
+
verbose_debug=False,
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`from_shp` will:
|
|
127
|
+
|
|
128
|
+
- Read the shapefile using `geopandas`
|
|
129
|
+
- Reproject to EPSG:4326 if necessary
|
|
130
|
+
- Use the overall bounding box (`total_bounds`) to determine the tile range
|
|
131
|
+
|
|
132
|
+
> Note: `geopandas` is only required if you use `from_shp`.
|
|
133
|
+
> You can install it via `pip install geopandas`.
|
|
134
|
+
|
|
135
|
+
------
|
|
136
|
+
|
|
137
|
+
## API Overview
|
|
138
|
+
|
|
139
|
+
### `TileDownloader`
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
@dataclass
|
|
143
|
+
class TileDownloader:
|
|
144
|
+
tile_list: Sequence[mercantile.Tile]
|
|
145
|
+
mk: str = "..."
|
|
146
|
+
tk: str = "..."
|
|
147
|
+
zoom: Optional[int] = None
|
|
148
|
+
|
|
149
|
+
def download_tiles(
|
|
150
|
+
self,
|
|
151
|
+
output_folder: Union[str, Path],
|
|
152
|
+
tile_list: Optional[Sequence[Tile]] = None,
|
|
153
|
+
verbose_debug: bool = False,
|
|
154
|
+
) -> None:
|
|
155
|
+
...
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Key methods:**
|
|
159
|
+
|
|
160
|
+
- `TileDownloader.from_lon_lat(min_lat, max_lat, min_lon, max_lon, zoom, mk, tk)`
|
|
161
|
+
Build a downloader from a bounding box.
|
|
162
|
+
- `TileDownloader.from_shp(shp_path, zoom, mk, tk)`
|
|
163
|
+
Build a downloader from a shapefile boundary.
|
|
164
|
+
- `TileDownloader.from_XYZs(tiles, mk, tk)`
|
|
165
|
+
Build a downloader from an existing list of `mercantile.Tile` objects (all at the same zoom level).
|
|
166
|
+
- `TileDownloader.download_tiles(output_folder, tile_list=None, verbose_debug=False)`
|
|
167
|
+
Download all tiles and save as GeoTIFF files (`z/x/y.tif`), skipping files that already exist.
|
|
168
|
+
|
|
169
|
+
------
|
|
170
|
+
|
|
171
|
+
## Output Format
|
|
172
|
+
|
|
173
|
+
Each downloaded tile is saved as a **multi-band GeoTIFF**:
|
|
174
|
+
|
|
175
|
+
- Filename: `output_folder/z/x/y.tif`
|
|
176
|
+
- Coordinate reference system: `EPSG:4326`
|
|
177
|
+
- Transform: computed from the tile’s geographic bounds via `rasterio.transform.from_bounds`
|
|
178
|
+
- Compression: LZW (`Compression.lzw`)
|
|
179
|
+
- Bands: derived from the source image (e.g., RGB → 3 bands)
|
|
180
|
+
|
|
181
|
+
------
|
|
182
|
+
|
|
183
|
+
## Requirements
|
|
184
|
+
|
|
185
|
+
Core dependencies (installed automatically when you install `tilekit`):
|
|
186
|
+
|
|
187
|
+
- `mercantile`
|
|
188
|
+
- `rasterio`
|
|
189
|
+
- `numpy`
|
|
190
|
+
- `requests`
|
|
191
|
+
- `Pillow`
|
|
192
|
+
- `tqdm`
|
|
193
|
+
|
|
194
|
+
Optional (for shapefile-based workflows):
|
|
195
|
+
|
|
196
|
+
- `geopandas`
|
|
197
|
+
|
|
198
|
+
------
|
|
199
|
+
|
|
200
|
+
## Notes
|
|
201
|
+
|
|
202
|
+
- You must provide valid **JL1 API keys** (`mk`, `tk`) to access the tile service.
|
|
203
|
+
The examples in this README use placeholder values; please replace them with your own.
|
|
204
|
+
- The downloader includes:
|
|
205
|
+
- A precheck step that skips already downloaded tiles
|
|
206
|
+
- Randomized delays between requests via `human_delay` to reduce the risk of being rate-limited
|
|
207
|
+
|
|
208
|
+
------
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
`tilekit` is released under the **Apache 2.0** License.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
tilekit/__init__.py,sha256=ebd7VvlOXENO1uUjhCA9kN-UAcZkDhENuZEklTfY3Qw,40
|
|
2
|
+
tilekit/downloader.py,sha256=HNlh7MdcVDcJ4w7IBhOJIkVbWB0eHMLpDCIDyxldFag,14549
|
|
3
|
+
tilekit/error_info.py,sha256=LI19P-Xw0GGP7Wi4u_SLJAyDTBR0nxRUw0ZPsFeC1aQ,17594
|
|
4
|
+
tilekit-0.1.0.dist-info/METADATA,sha256=jz9f4VmISaWWanLd-qg0ytvHiDLSwLYV0NYw9__4n_I,5109
|
|
5
|
+
tilekit-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
tilekit-0.1.0.dist-info/RECORD,,
|