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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any