[refactor] clean download_extent function
Browse files- src/io/tms2geotiff.py +73 -172
- src/utilities/constants.py +10 -4
src/io/tms2geotiff.py
CHANGED
@@ -34,14 +34,13 @@ import io
|
|
34 |
import itertools
|
35 |
import math
|
36 |
import re
|
37 |
-
import sqlite3
|
38 |
import time
|
39 |
|
40 |
from PIL import Image
|
41 |
|
42 |
from src import app_logger
|
43 |
-
from src.utilities.constants import EARTH_EQUATORIAL_RADIUS
|
44 |
-
|
45 |
|
46 |
Image.MAX_IMAGE_PIXELS = None
|
47 |
|
@@ -64,16 +63,16 @@ re_coords_split = re.compile('[ ,;]+')
|
|
64 |
|
65 |
|
66 |
def from4326_to3857(lat, lon):
|
67 |
-
|
68 |
-
|
69 |
-
return
|
70 |
|
71 |
|
72 |
def deg2num(lat, lon, zoom):
|
73 |
n = 2 ** zoom
|
74 |
-
|
75 |
-
|
76 |
-
return
|
77 |
|
78 |
|
79 |
def is_empty(im):
|
@@ -89,56 +88,40 @@ def is_empty(im):
|
|
89 |
return extrema[0] == (0, 0)
|
90 |
|
91 |
|
92 |
-
def
|
93 |
-
db = sqlite3.connect(dbname, isolation_level=None)
|
94 |
-
cur = db.cursor()
|
95 |
-
cur.execute("BEGIN")
|
96 |
-
cur.execute("CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT)")
|
97 |
-
cur.execute("CREATE TABLE IF NOT EXISTS tiles ("
|
98 |
-
"zoom_level INTEGER NOT NULL, "
|
99 |
-
"tile_column INTEGER NOT NULL, "
|
100 |
-
"tile_row INTEGER NOT NULL, "
|
101 |
-
"tile_data BLOB NOT NULL, "
|
102 |
-
"UNIQUE (zoom_level, tile_column, tile_row)"
|
103 |
-
")")
|
104 |
-
cur.execute("COMMIT")
|
105 |
-
return db
|
106 |
-
|
107 |
-
|
108 |
-
def paste_tile(bigim, base_size, tile, corner_xy, bbox):
|
109 |
if tile is None:
|
110 |
-
return
|
111 |
im = Image.open(io.BytesIO(tile))
|
112 |
mode = 'RGB' if im.mode == 'RGB' else 'RGBA'
|
113 |
size = im.size
|
114 |
-
if
|
115 |
base_size[0] = size[0]
|
116 |
base_size[1] = size[1]
|
117 |
-
|
118 |
size[0] * (bbox[2] - bbox[0]), size[1] * (bbox[3] - bbox[1])))
|
119 |
else:
|
120 |
-
|
121 |
|
122 |
dx = abs(corner_xy[0] - bbox[0])
|
123 |
dy = abs(corner_xy[1] - bbox[1])
|
124 |
xy0 = (size[0] * dx, size[1] * dy)
|
125 |
if mode == 'RGB':
|
126 |
-
|
127 |
else:
|
128 |
if im.mode != mode:
|
129 |
im = im.convert(mode)
|
130 |
if not is_empty(im):
|
131 |
-
|
132 |
im.close()
|
133 |
-
return
|
134 |
|
135 |
|
136 |
def get_tile(url):
|
137 |
-
retry =
|
138 |
while 1:
|
139 |
try:
|
140 |
app_logger.debug(f"image tile url to download: {url}.")
|
141 |
-
r = SESSION.get(url, timeout=
|
142 |
break
|
143 |
except Exception as request_tile_exception:
|
144 |
app_logger.error(f"retry {retry}, request_tile_exception:{request_tile_exception}.")
|
@@ -156,44 +139,10 @@ def print_progress(progress, total, done=False):
|
|
156 |
app_logger.info('Downloaded image %d/%d, %.2f%%' % (progress, total, progress * 100 / total))
|
157 |
|
158 |
|
159 |
-
def mbtiles_save(db, img_data, xy, zoom, img_format):
|
160 |
-
if not img_data:
|
161 |
-
return
|
162 |
-
im = Image.open(io.BytesIO(img_data))
|
163 |
-
if im.format == 'PNG':
|
164 |
-
current_format = 'png'
|
165 |
-
elif im.format == 'JPEG':
|
166 |
-
current_format = 'jpg'
|
167 |
-
elif im.format == 'WEBP':
|
168 |
-
current_format = 'webp'
|
169 |
-
else:
|
170 |
-
current_format = 'image/' + im.format.lower()
|
171 |
-
x, y = xy
|
172 |
-
y = 2 ** zoom - 1 - y
|
173 |
-
cur = db.cursor()
|
174 |
-
if img_format is None or img_format == current_format:
|
175 |
-
cur.execute("REPLACE INTO tiles VALUES (?,?,?,?)", (
|
176 |
-
zoom, x, y, img_data))
|
177 |
-
return img_format or current_format
|
178 |
-
buf = io.BytesIO()
|
179 |
-
if img_format == 'png':
|
180 |
-
im.save(buf, 'PNG')
|
181 |
-
elif img_format == 'jpg':
|
182 |
-
im.save(buf, 'JPEG', quality=93)
|
183 |
-
elif img_format == 'webp':
|
184 |
-
im.save(buf, 'WEBP')
|
185 |
-
else:
|
186 |
-
im.save(buf, img_format.split('/')[-1].upper())
|
187 |
-
cur.execute("REPLACE INTO tiles VALUES (?,?,?,?)", (
|
188 |
-
zoom, x, y, buf.getvalue()))
|
189 |
-
return img_format
|
190 |
-
|
191 |
-
|
192 |
def download_extent(
|
193 |
source, lat0, lon0, lat1, lon1, zoom,
|
194 |
-
|
195 |
-
|
196 |
-
callback_interval=0.05
|
197 |
):
|
198 |
x0, y0 = deg2num(lat0, lon0, zoom)
|
199 |
x1, y1 = deg2num(lat1, lon1, zoom)
|
@@ -202,59 +151,13 @@ def download_extent(
|
|
202 |
if y0 > y1:
|
203 |
y0, y1 = y1, y0
|
204 |
|
205 |
-
db = None
|
206 |
-
mbt_img_format = None
|
207 |
-
if mbtiles:
|
208 |
-
db = mbtiles_init(mbtiles)
|
209 |
-
cur = db.cursor()
|
210 |
-
cur.execute("BEGIN")
|
211 |
-
cur.execute("REPLACE INTO metadata VALUES ('name', ?)", (source,))
|
212 |
-
cur.execute("REPLACE INTO metadata VALUES ('type', 'overlay')")
|
213 |
-
cur.execute("REPLACE INTO metadata VALUES ('version', '1.1')")
|
214 |
-
cur.execute("REPLACE INTO metadata VALUES ('description', ?)", (source,))
|
215 |
-
cur.execute("SELECT value FROM metadata WHERE name='format'")
|
216 |
-
row = cur.fetchone()
|
217 |
-
if row and row[0]:
|
218 |
-
mbt_img_format = row[0]
|
219 |
-
else:
|
220 |
-
cur.execute("REPLACE INTO metadata VALUES ('format', 'png')")
|
221 |
-
|
222 |
-
lat_min = min(lat0, lat1)
|
223 |
-
lat_max = max(lat0, lat1)
|
224 |
-
lon_min = min(lon0, lon1)
|
225 |
-
lon_max = max(lon0, lon1)
|
226 |
-
bounds = [lon_min, lat_min, lon_max, lat_max]
|
227 |
-
cur.execute("SELECT value FROM metadata WHERE name='bounds'")
|
228 |
-
row = cur.fetchone()
|
229 |
-
if row and row[0]:
|
230 |
-
last_bounds = [float(x) for x in row[0].split(',')]
|
231 |
-
bounds[0] = min(last_bounds[0], bounds[0])
|
232 |
-
bounds[1] = min(last_bounds[1], bounds[1])
|
233 |
-
bounds[2] = max(last_bounds[2], bounds[2])
|
234 |
-
bounds[3] = max(last_bounds[3], bounds[3])
|
235 |
-
cur.execute("REPLACE INTO metadata VALUES ('bounds', ?)", (
|
236 |
-
",".join(map(str, bounds)),))
|
237 |
-
cur.execute("REPLACE INTO metadata VALUES ('center', ?)", ("%s,%s,%d" % (
|
238 |
-
(lon_max + lon_min) / 2, (lat_max + lat_min) / 2, zoom),))
|
239 |
-
cur.execute("""
|
240 |
-
INSERT INTO metadata VALUES ('minzoom', ?)
|
241 |
-
ON CONFLICT(name) DO UPDATE SET value=excluded.value
|
242 |
-
WHERE CAST(excluded.value AS INTEGER)<CAST(metadata.value AS INTEGER)
|
243 |
-
""", (str(zoom),))
|
244 |
-
cur.execute("""
|
245 |
-
INSERT INTO metadata VALUES ('maxzoom', ?)
|
246 |
-
ON CONFLICT(name) DO UPDATE SET value=excluded.value
|
247 |
-
WHERE CAST(excluded.value AS INTEGER)>CAST(metadata.value AS INTEGER)
|
248 |
-
""", (str(zoom),))
|
249 |
-
cur.execute("COMMIT")
|
250 |
-
|
251 |
corners = tuple(itertools.product(
|
252 |
range(math.floor(x0), math.ceil(x1)),
|
253 |
range(math.floor(y0), math.ceil(y1))))
|
254 |
-
|
255 |
futures = {}
|
256 |
done_num = 0
|
257 |
-
progress_callback(done_num,
|
258 |
last_done_num = 0
|
259 |
last_callback = time.monotonic()
|
260 |
cancelled = False
|
@@ -263,67 +166,65 @@ def download_extent(
|
|
263 |
future = executor.submit(get_tile, source.format(z=zoom, x=x, y=y))
|
264 |
futures[future] = (x, y)
|
265 |
bbox = (math.floor(x0), math.floor(y0), math.ceil(x1), math.ceil(y1))
|
266 |
-
|
267 |
-
base_size = [
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
)
|
273 |
-
cur = None
|
274 |
-
if mbtiles:
|
275 |
-
cur = db.cursor()
|
276 |
-
cur.execute("BEGIN")
|
277 |
-
for fut in done:
|
278 |
-
img_data = fut.result()
|
279 |
-
xy = futures[fut]
|
280 |
-
if save_image:
|
281 |
-
bigim = paste_tile(bigim, base_size, img_data, xy, bbox)
|
282 |
-
if mbtiles:
|
283 |
-
new_format = mbtiles_save(db, img_data, xy, zoom, mbt_img_format)
|
284 |
-
if not mbt_img_format:
|
285 |
-
cur.execute(
|
286 |
-
"UPDATE metadata SET value=? WHERE name='format'",
|
287 |
-
(new_format,))
|
288 |
-
mbt_img_format = new_format
|
289 |
-
del futures[fut]
|
290 |
-
done_num += 1
|
291 |
-
if mbtiles:
|
292 |
-
cur.execute("COMMIT")
|
293 |
-
if time.monotonic() > last_callback + callback_interval:
|
294 |
-
try:
|
295 |
-
progress_callback(done_num, totalnum, (done_num > last_done_num))
|
296 |
-
except TaskCancelled:
|
297 |
-
for fut in futures.keys():
|
298 |
-
fut.cancel()
|
299 |
-
futures.clear()
|
300 |
-
cancelled = True
|
301 |
-
break
|
302 |
-
last_callback = time.monotonic()
|
303 |
-
last_done_num = done_num
|
304 |
if cancelled:
|
305 |
raise TaskCancelled()
|
306 |
-
progress_callback(done_num,
|
307 |
|
308 |
if not save_image:
|
309 |
return None, None
|
310 |
|
311 |
-
|
312 |
-
|
313 |
-
x2 = round(base_size[0] *
|
314 |
-
y2 = round(base_size[1] *
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
if
|
319 |
-
|
320 |
-
|
321 |
xp0, yp0 = from4326_to3857(lat0, lon0)
|
322 |
xp1, yp1 = from4326_to3857(lat1, lon1)
|
323 |
-
|
324 |
-
|
325 |
-
matrix = (min(xp0, xp1),
|
326 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
327 |
|
328 |
|
329 |
class TaskCancelled(RuntimeError):
|
|
|
34 |
import itertools
|
35 |
import math
|
36 |
import re
|
|
|
37 |
import time
|
38 |
|
39 |
from PIL import Image
|
40 |
|
41 |
from src import app_logger
|
42 |
+
from src.utilities.constants import EARTH_EQUATORIAL_RADIUS, RETRY_DOWNLOAD, TIMEOUT_DOWNLOAD, TILE_SIZE, \
|
43 |
+
CALLBACK_INTERVAL_DOWNLOAD
|
44 |
|
45 |
Image.MAX_IMAGE_PIXELS = None
|
46 |
|
|
|
63 |
|
64 |
|
65 |
def from4326_to3857(lat, lon):
|
66 |
+
x_tile = math.radians(lon) * EARTH_EQUATORIAL_RADIUS
|
67 |
+
y_tile = math.log(math.tan(math.radians(45 + lat / 2.0))) * EARTH_EQUATORIAL_RADIUS
|
68 |
+
return x_tile, y_tile
|
69 |
|
70 |
|
71 |
def deg2num(lat, lon, zoom):
|
72 |
n = 2 ** zoom
|
73 |
+
x_tile = ((lon + 180) / 360 * n)
|
74 |
+
y_tile = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) * n / 2
|
75 |
+
return x_tile, y_tile
|
76 |
|
77 |
|
78 |
def is_empty(im):
|
|
|
88 |
return extrema[0] == (0, 0)
|
89 |
|
90 |
|
91 |
+
def paste_tile(big_im, base_size, tile, corner_xy, bbox):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
if tile is None:
|
93 |
+
return big_im
|
94 |
im = Image.open(io.BytesIO(tile))
|
95 |
mode = 'RGB' if im.mode == 'RGB' else 'RGBA'
|
96 |
size = im.size
|
97 |
+
if big_im is None:
|
98 |
base_size[0] = size[0]
|
99 |
base_size[1] = size[1]
|
100 |
+
new_im = Image.new(mode, (
|
101 |
size[0] * (bbox[2] - bbox[0]), size[1] * (bbox[3] - bbox[1])))
|
102 |
else:
|
103 |
+
new_im = big_im
|
104 |
|
105 |
dx = abs(corner_xy[0] - bbox[0])
|
106 |
dy = abs(corner_xy[1] - bbox[1])
|
107 |
xy0 = (size[0] * dx, size[1] * dy)
|
108 |
if mode == 'RGB':
|
109 |
+
new_im.paste(im, xy0)
|
110 |
else:
|
111 |
if im.mode != mode:
|
112 |
im = im.convert(mode)
|
113 |
if not is_empty(im):
|
114 |
+
new_im.paste(im, xy0)
|
115 |
im.close()
|
116 |
+
return new_im
|
117 |
|
118 |
|
119 |
def get_tile(url):
|
120 |
+
retry = RETRY_DOWNLOAD
|
121 |
while 1:
|
122 |
try:
|
123 |
app_logger.debug(f"image tile url to download: {url}.")
|
124 |
+
r = SESSION.get(url, timeout=TIMEOUT_DOWNLOAD)
|
125 |
break
|
126 |
except Exception as request_tile_exception:
|
127 |
app_logger.error(f"retry {retry}, request_tile_exception:{request_tile_exception}.")
|
|
|
139 |
app_logger.info('Downloaded image %d/%d, %.2f%%' % (progress, total, progress * 100 / total))
|
140 |
|
141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
142 |
def download_extent(
|
143 |
source, lat0, lon0, lat1, lon1, zoom,
|
144 |
+
save_image=True, progress_callback=print_progress,
|
145 |
+
callback_interval=CALLBACK_INTERVAL_DOWNLOAD
|
|
|
146 |
):
|
147 |
x0, y0 = deg2num(lat0, lon0, zoom)
|
148 |
x1, y1 = deg2num(lat1, lon1, zoom)
|
|
|
151 |
if y0 > y1:
|
152 |
y0, y1 = y1, y0
|
153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
corners = tuple(itertools.product(
|
155 |
range(math.floor(x0), math.ceil(x1)),
|
156 |
range(math.floor(y0), math.ceil(y1))))
|
157 |
+
total_num = len(corners)
|
158 |
futures = {}
|
159 |
done_num = 0
|
160 |
+
progress_callback(done_num, total_num, False)
|
161 |
last_done_num = 0
|
162 |
last_callback = time.monotonic()
|
163 |
cancelled = False
|
|
|
166 |
future = executor.submit(get_tile, source.format(z=zoom, x=x, y=y))
|
167 |
futures[future] = (x, y)
|
168 |
bbox = (math.floor(x0), math.floor(y0), math.ceil(x1), math.ceil(y1))
|
169 |
+
big_im = None
|
170 |
+
base_size = [TILE_SIZE, TILE_SIZE]
|
171 |
+
big_im, cancelled, done_num = run_future_tile_download(
|
172 |
+
base_size, bbox, big_im, callback_interval, cancelled, done_num, futures, last_callback, last_done_num,
|
173 |
+
progress_callback, save_image, total_num
|
174 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
175 |
if cancelled:
|
176 |
raise TaskCancelled()
|
177 |
+
progress_callback(done_num, total_num, True)
|
178 |
|
179 |
if not save_image:
|
180 |
return None, None
|
181 |
|
182 |
+
x_frac = x0 - bbox[0]
|
183 |
+
y_frac = y0 - bbox[1]
|
184 |
+
x2 = round(base_size[0] * x_frac)
|
185 |
+
y2 = round(base_size[1] * y_frac)
|
186 |
+
img_w = round(base_size[0] * (x1 - x0))
|
187 |
+
img_h = round(base_size[1] * (y1 - y0))
|
188 |
+
ret_im = big_im.crop((x2, y2, x2 + img_w, y2 + img_h))
|
189 |
+
if ret_im.mode == 'RGBA' and ret_im.getextrema()[3] == (255, 255):
|
190 |
+
ret_im = ret_im.convert('RGB')
|
191 |
+
big_im.close()
|
192 |
xp0, yp0 = from4326_to3857(lat0, lon0)
|
193 |
xp1, yp1 = from4326_to3857(lat1, lon1)
|
194 |
+
p_width = abs(xp1 - xp0) / ret_im.size[0]
|
195 |
+
p_height = abs(yp1 - yp0) / ret_im.size[1]
|
196 |
+
matrix = (min(xp0, xp1), p_width, 0, max(yp0, yp1), 0, -p_height)
|
197 |
+
return ret_im, matrix
|
198 |
+
|
199 |
+
|
200 |
+
def run_future_tile_download(
|
201 |
+
base_size, bbox, big_im, callback_interval, cancelled, done_num, futures, last_callback, last_done_num,
|
202 |
+
progress_callback, save_image, total_num
|
203 |
+
):
|
204 |
+
while futures:
|
205 |
+
done, _ = concurrent.futures.wait(
|
206 |
+
futures.keys(), timeout=callback_interval,
|
207 |
+
return_when=concurrent.futures.FIRST_COMPLETED
|
208 |
+
)
|
209 |
+
for fut in done:
|
210 |
+
img_data = fut.result()
|
211 |
+
xy = futures[fut]
|
212 |
+
if save_image:
|
213 |
+
big_im = paste_tile(big_im, base_size, img_data, xy, bbox)
|
214 |
+
del futures[fut]
|
215 |
+
done_num += 1
|
216 |
+
if time.monotonic() > last_callback + callback_interval:
|
217 |
+
try:
|
218 |
+
progress_callback(done_num, total_num, (done_num > last_done_num))
|
219 |
+
except TaskCancelled:
|
220 |
+
for fut in futures.keys():
|
221 |
+
fut.cancel()
|
222 |
+
futures.clear()
|
223 |
+
cancelled = True
|
224 |
+
break
|
225 |
+
last_callback = time.monotonic()
|
226 |
+
last_done_num = done_num
|
227 |
+
return big_im, cancelled, done_num
|
228 |
|
229 |
|
230 |
class TaskCancelled(RuntimeError):
|
src/utilities/constants.py
CHANGED
@@ -13,9 +13,15 @@ MODEL_DECODER_NAME = "sam_vit_h_4b8939.decoder.onnx"
|
|
13 |
TILE_SIZE = 256
|
14 |
EARTH_EQUATORIAL_RADIUS = 6378137.0
|
15 |
DEFAULT_TMS = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
16 |
-
WKT_3857 = 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,
|
17 |
-
WKT_3857 += 'AUTHORITY["EPSG","
|
18 |
-
WKT_3857 += '
|
19 |
-
WKT_3857 += '"
|
|
|
|
|
|
|
20 |
SERVICE_NAME = "sam-gis"
|
21 |
DEFAULT_LOG_LEVEL = 'INFO'
|
|
|
|
|
|
|
|
13 |
TILE_SIZE = 256
|
14 |
EARTH_EQUATORIAL_RADIUS = 6378137.0
|
15 |
DEFAULT_TMS = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
16 |
+
WKT_3857 = 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,'
|
17 |
+
WKT_3857 += 'AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],'
|
18 |
+
WKT_3857 += 'UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],'
|
19 |
+
WKT_3857 += 'PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],'
|
20 |
+
WKT_3857 += 'PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],'
|
21 |
+
WKT_3857 += 'AXIS["X",EAST],AXIS["Y",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 '
|
22 |
+
WKT_3857 += '+x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]'
|
23 |
SERVICE_NAME = "sam-gis"
|
24 |
DEFAULT_LOG_LEVEL = 'INFO'
|
25 |
+
RETRY_DOWNLOAD = 3
|
26 |
+
TIMEOUT_DOWNLOAD = 60
|
27 |
+
CALLBACK_INTERVAL_DOWNLOAD = 0.05
|