File size: 8,336 Bytes
3e8e2d5
0aa759d
3e8e2d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0aa759d
ee50e01
0aa759d
ee50e01
0aa759d
 
3209d49
 
ee50e01
 
0aa759d
ddd32eb
 
3209d49
ee50e01
 
 
 
 
9271aef
ee50e01
 
 
 
9271aef
ee50e01
 
 
 
 
 
 
 
 
 
 
ddd32eb
 
 
ee50e01
 
 
 
ddd32eb
 
 
ee50e01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddd32eb
ee50e01
ddd32eb
ee50e01
 
 
ddd32eb
ee50e01
 
ddd32eb
9271aef
ee50e01
ddd32eb
ee50e01
 
 
9271aef
ee50e01
ddd32eb
ee50e01
 
 
 
ddd32eb
ee50e01
ddd32eb
ee50e01
 
 
ddd32eb
ee50e01
 
bb53beb
ddd32eb
ee50e01
9271aef
 
ee50e01
 
 
4e35839
ee50e01
 
 
 
 
 
 
85dd36a
ee50e01
 
 
3209d49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee50e01
 
 
 
 
 
 
 
 
 
ddd32eb
ee50e01
 
ddd32eb
ee50e01
 
 
 
 
 
9271aef
ee50e01
ddd32eb
 
 
 
 
 
ee50e01
 
ddd32eb
ee50e01
 
 
 
ddd32eb
 
 
 
 
 
3209d49
 
 
ddd32eb
ee50e01
 
3209d49
 
 
 
ddd32eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee50e01
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""
Download geo-referenced raster tiles images.
Modified from https://github.com/gumblex/tms2geotiff/

BSD 2-Clause License

Copyright (c) 2019, Dingyuan Wang
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
  list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import concurrent.futures
import io
import itertools
import math
import re
import time
from typing import Tuple, Callable
import PIL
from PIL import Image

from src import app_logger
from src.utilities.constants import EARTH_EQUATORIAL_RADIUS, RETRY_DOWNLOAD, TIMEOUT_DOWNLOAD, TILE_SIZE, \
    CALLBACK_INTERVAL_DOWNLOAD
from src.utilities.type_hints import PIL_Image

Image.MAX_IMAGE_PIXELS = None

try:
    import httpx

    SESSION = httpx.Client()
except ImportError:
    import requests

    SESSION = requests.Session()

SESSION.headers.update({
    "Accept": "*/*",
    "Accept-Encoding": "gzip, deflate",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0",
})

re_coords_split = re.compile('[ ,;]+')


def from4326_to3857(lat, lon):
    x_tile = math.radians(lon) * EARTH_EQUATORIAL_RADIUS
    y_tile = math.log(math.tan(math.radians(45 + lat / 2.0))) * EARTH_EQUATORIAL_RADIUS
    return x_tile, y_tile


def deg2num(lat, lon, zoom):
    n = 2 ** zoom
    x_tile = ((lon + 180) / 360 * n)
    y_tile = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) * n / 2
    return x_tile, y_tile


def is_empty(im):
    extrema = im.getextrema()
    if len(extrema) >= 3:
        if len(extrema) > 3 and extrema[-1] == (0, 0):
            return True
        for ext in extrema[:3]:
            if ext != (0, 0):
                return False
        return True
    else:
        return extrema[0] == (0, 0)


def paste_tile(big_im, base_size, tile, corner_xy, bbox):
    if tile is None:
        return big_im
    im = Image.open(io.BytesIO(tile))
    mode = 'RGB' if im.mode == 'RGB' else 'RGBA'
    size = im.size
    if big_im is None:
        base_size[0] = size[0]
        base_size[1] = size[1]
        new_im = Image.new(mode, (
            size[0] * (bbox[2] - bbox[0]), size[1] * (bbox[3] - bbox[1])))
    else:
        new_im = big_im

    dx = abs(corner_xy[0] - bbox[0])
    dy = abs(corner_xy[1] - bbox[1])
    xy0 = (size[0] * dx, size[1] * dy)
    if mode == 'RGB':
        new_im.paste(im, xy0)
    else:
        if im.mode != mode:
            im = im.convert(mode)
        if not is_empty(im):
            new_im.paste(im, xy0)
    im.close()
    return new_im


def get_tile(url):
    retry = RETRY_DOWNLOAD
    while 1:
        try:
            app_logger.debug(f"image tile url to download: {url}.")
            r = SESSION.get(url, timeout=TIMEOUT_DOWNLOAD)
            break
        except Exception as request_tile_exception:
            app_logger.error(f"retry {retry}, request_tile_exception:{request_tile_exception}.")
            retry -= 1
            if not retry:
                raise
    if r.status_code == 404 or not r.content:
        return None
    r.raise_for_status()
    return r.content


def print_progress(progress, total, done=False):
    if done:
        app_logger.info('Downloaded image %d/%d, %.2f%%' % (progress, total, progress * 100 / total))


def download_extent(
        source: str, lat0: float, lon0: float, lat1: float, lon1: float, zoom: int,
        save_image: bool = True, progress_callback: Callable = print_progress,
        callback_interval: float = CALLBACK_INTERVAL_DOWNLOAD
) -> Tuple[PIL_Image, Tuple[float]] or Tuple[None]:
    """
    Download, merge and crop a list of tiles into a single geo-referenced image or a raster geodata

    Args:
        source: remote url tile
        lat0: point0 bounding box latitude
        lat1: point0 bounding box longitude
        lon0: point1 bounding box latitude
        lon1: point1 bounding box longitude
        zoom: bounding box zoom
        save_image: boolean to choose if save the image
        progress_callback: callback function
        callback_interval: process callback interval time

    Returns:
        parsed request input
    """
    x0, y0 = deg2num(lat0, lon0, zoom)
    x1, y1 = deg2num(lat1, lon1, zoom)
    if x0 > x1:
        x0, x1 = x1, x0
    if y0 > y1:
        y0, y1 = y1, y0

    corners = tuple(itertools.product(
        range(math.floor(x0), math.ceil(x1)),
        range(math.floor(y0), math.ceil(y1))))
    total_num = len(corners)
    futures = {}
    done_num = 0
    progress_callback(done_num, total_num, False)
    last_done_num = 0
    last_callback = time.monotonic()
    cancelled = False
    with concurrent.futures.ThreadPoolExecutor(5) as executor:
        for x, y in corners:
            future = executor.submit(get_tile, source.format(z=zoom, x=x, y=y))
            futures[future] = (x, y)
        bbox = (math.floor(x0), math.floor(y0), math.ceil(x1), math.ceil(y1))
        big_im = None
        base_size = [TILE_SIZE, TILE_SIZE]
        big_im, cancelled, done_num = run_future_tile_download(
            base_size, bbox, big_im, callback_interval, cancelled, done_num, futures, last_callback, last_done_num,
            progress_callback, save_image, total_num
        )
    if cancelled:
        raise TaskCancelled()
    progress_callback(done_num, total_num, True)

    if not save_image:
        return None, None

    x_frac = x0 - bbox[0]
    y_frac = y0 - bbox[1]
    x2 = round(base_size[0] * x_frac)
    y2 = round(base_size[1] * y_frac)
    img_w = round(base_size[0] * (x1 - x0))
    img_h = round(base_size[1] * (y1 - y0))
    final_image = big_im.crop((x2, y2, x2 + img_w, y2 + img_h))
    if final_image.mode == 'RGBA' and final_image.getextrema()[3] == (255, 255):
        final_image = final_image.convert('RGB')
    big_im.close()
    xp0, yp0 = from4326_to3857(lat0, lon0)
    xp1, yp1 = from4326_to3857(lat1, lon1)
    p_width = abs(xp1 - xp0) / final_image.size[0]
    p_height = abs(yp1 - yp0) / final_image.size[1]
    matrix = min(xp0, xp1), p_width, 0, max(yp0, yp1), 0, -p_height
    return final_image, matrix


def run_future_tile_download(
        base_size, bbox, big_im, callback_interval, cancelled, done_num, futures, last_callback, last_done_num,
        progress_callback, save_image, total_num
):
    while futures:
        done, _ = concurrent.futures.wait(
            futures.keys(), timeout=callback_interval,
            return_when=concurrent.futures.FIRST_COMPLETED
        )
        for fut in done:
            img_data = fut.result()
            xy = futures[fut]
            if save_image:
                big_im = paste_tile(big_im, base_size, img_data, xy, bbox)
            del futures[fut]
            done_num += 1
        if time.monotonic() > last_callback + callback_interval:
            try:
                progress_callback(done_num, total_num, (done_num > last_done_num))
            except TaskCancelled:
                for fut in futures.keys():
                    fut.cancel()
                futures.clear()
                cancelled = True
                break
            last_callback = time.monotonic()
            last_done_num = done_num
    return big_im, cancelled, done_num


class TaskCancelled(RuntimeError):
    pass