1
+ import math
2
+ import logging
1
3
from enum import Enum
2
- from typing import Optional , List
4
+ from typing import Optional , List , Tuple
5
+ from concurrent .futures import ThreadPoolExecutor
3
6
4
- from pydantic import BaseModel , validator , conlist
7
+ import requests
5
8
import numpy as np
9
+ import tensorflow as tf
10
+ from retry import retry
6
11
from pyproj import Transformer
12
+ from pydantic import BaseModel , validator , conlist
13
+ from pydantic .class_validators import root_validator
7
14
8
15
from ..geometry import Point
9
16
from .base_data import BaseData
10
17
from .raster import RasterData
18
+ """TODO: consider how to swap lat,lng to lng,lt when version = 2...
19
+ should the bounds validator be inside the TiledImageData class then,
20
+ since we need to check on Version?
21
+ """
22
+
23
+ VALID_LAT_RANGE = range (- 90 , 90 )
24
+ VALID_LNG_RANGE = range (- 180 , 180 )
25
+ TMS_TILE_SIZE = 256
26
+ MAX_TILES = 300 #TODO: thinking of how to choose an appropriate max tiles number. 18 seems too small, but over 1000 likely seems too large
27
+ TILE_DOWNLOAD_CONCURRENCY = 4
28
+
29
+ logging .basicConfig (level = logging .INFO )
30
+ logger = logging .getLogger (__name__ )
11
31
12
32
13
33
class EPSG (Enum ):
@@ -26,7 +46,7 @@ class EPSG(Enum):
26
46
class TiledBounds (BaseModel ):
27
47
""" Bounds for a tiled image asset related to the relevant epsg.
28
48
29
- Bounds should be Point objects
49
+ Bounds should be Point objects. Currently, we support bounds in EPSG 4326.
30
50
31
51
If version of asset is 2, these should be [[lat,lng],[lat,lng]]
32
52
If version of asset is 1, these should be [[lng,lat]],[lng,lat]]
@@ -40,14 +60,31 @@ class TiledBounds(BaseModel):
40
60
bounds : List [Point ]
41
61
42
62
@validator ('bounds' )
43
- def validate_bounds (cls , bounds ):
63
+ def validate_bounds_not_equal (cls , bounds ):
44
64
first_bound = bounds [0 ]
45
65
second_bound = bounds [1 ]
46
66
47
67
if first_bound == second_bound :
48
68
raise AssertionError (f"Bounds cannot be equal, contains { bounds } " )
49
69
return bounds
50
70
71
+ #bounds are assumed to be in EPSG 4326 as that is what leaflet assumes
72
+ @root_validator
73
+ def validate_bounds_lat_lng (cls , values ):
74
+ epsg = values .get ('epsg' )
75
+ bounds = values .get ('bounds' )
76
+ #TODO: look into messaging that we only support 4326 right now. raise exception, not implemented
77
+
78
+ if epsg != EPSG .SIMPLEPIXEL :
79
+ for bound in bounds :
80
+ lat , lng = bound .y , bound .x
81
+ if int (lng ) not in VALID_LNG_RANGE or int (
82
+ lat ) not in VALID_LAT_RANGE :
83
+ raise ValueError (f"Invalid lat/lng bounds. Found { bounds } . "
84
+ "lat must be in {VALID_LAT_RANGE}. "
85
+ "lng must be in {VALID_LNG_RANGE}." )
86
+ return values
87
+
51
88
52
89
class TileLayer (BaseModel ):
53
90
""" Url that contains the tile layer. Must be in the format:
@@ -83,28 +120,198 @@ class TiledImageData(BaseData):
83
120
max_native_zoom: int = None
84
121
tile_size: Optional[int]
85
122
version: int = 2
86
- alternative_layers: List[TileLayer]
123
+ alternative_layers: List[TileLayer]
124
+
125
+ >>> tiled_image_data = TiledImageData(tile_layer=TileLayer,
126
+ tile_bounds=TiledBounds,
127
+ zoom_levels=[1, 12])
87
128
"""
88
129
tile_layer : TileLayer
89
130
tile_bounds : TiledBounds
90
131
alternative_layers : List [TileLayer ] = None
91
132
zoom_levels : conlist (item_type = int , min_items = 2 , max_items = 2 )
92
133
max_native_zoom : int = None
93
- tile_size : Optional [int ]
134
+ tile_size : Optional [int ] = TMS_TILE_SIZE
94
135
version : int = 2
95
136
96
- #TODO: look further into Matt's code and how to reference the monorepo ?
97
- def _as_raster (zoom ):
98
- # stitched together tiles as a RasterData object
99
- # TileData.get_image(target_hw) ← we will be using this from Matt's precomputed embeddings
100
- # more info found here: https://github.com/Labelbox/python-monorepo/blob/baac09cb89e083209644c9bdf1bc3d7cb218f147/services/precomputed_embeddings/precomputed_embeddings/tiled.py
101
- image_as_np = None
102
- return RasterData (arr = image_as_np )
137
+ def _as_raster (self , zoom = 0 ) -> RasterData :
138
+ """Converts the tiled image asset into a RasterData object containing an
139
+ np.ndarray.
140
+
141
+ Uses the minimum zoom provided to render the image.
142
+ """
143
+ if self .tile_bounds .epsg == EPSG .SIMPLEPIXEL :
144
+ xstart , ystart , xend , yend = self ._get_simple_image_params (zoom )
145
+
146
+ # Currently our editor doesn't support anything other than 3857.
147
+ # Since the user provided projection is ignored by the editor
148
+ # we will ignore it here and assume that the projection is 3857.
149
+ else :
150
+ if self .tile_bounds .epsg != EPSG .EPSG3857 :
151
+ logger .info (
152
+ f"User provided EPSG is being ignored { self .tile_bounds .epsg } ."
153
+ )
154
+ xstart , ystart , xend , yend = self ._get_3857_image_params (zoom )
155
+
156
+ total_n_tiles = (yend - ystart + 1 ) * (xend - xstart + 1 )
157
+ if total_n_tiles > MAX_TILES :
158
+ logger .info (
159
+ f"Too many tiles requested. Total tiles attempted { total_n_tiles } ."
160
+ )
161
+ return None
162
+
163
+ rounded_tiles , pixel_offsets = list (
164
+ zip (* [
165
+ self ._tile_to_pixel (pt ) for pt in [xstart , ystart , xend , yend ]
166
+ ]))
167
+
168
+ image = self ._fetch_image_for_bounds (* rounded_tiles , zoom )
169
+ arr = self ._crop_to_bounds (image , * pixel_offsets )
170
+ return RasterData (arr = arr )
103
171
104
- #TODO
105
172
@property
106
173
def value (self ) -> np .ndarray :
107
- return self ._as_raster (self .min_zoom ).value ()
174
+ """Returns the value of a generated RasterData object.
175
+ """
176
+ return self ._as_raster (self .zoom_levels [0 ]).value
177
+
178
+ def _get_simple_image_params (self ,
179
+ zoom ) -> Tuple [float , float , float , float ]:
180
+ """Computes the x and y tile bounds for fetching an image that
181
+ captures the entire labeling region (TiledData.bounds) given a specific zoom
182
+
183
+ Simple has different order of x / y than lat / lng because of how leaflet behaves
184
+ leaflet reports all points as pixel locations at a zoom of 0
185
+ """
186
+ xend , xstart , yend , ystart = (
187
+ self .tile_bounds .bounds [1 ].x ,
188
+ self .tile_bounds .bounds [0 ].x ,
189
+ self .tile_bounds .bounds [1 ].y ,
190
+ self .tile_bounds .bounds [0 ].y ,
191
+ )
192
+ return (* [
193
+ x * (2 ** (zoom )) / self .tile_size
194
+ for x in [xstart , ystart , xend , yend ]
195
+ ],)
196
+
197
+ def _get_3857_image_params (self , zoom ) -> Tuple [float , float , float , float ]:
198
+ """Computes the x and y tile bounds for fetching an image that
199
+ captures the entire labeling region (TiledData.bounds) given a specific zoom
200
+ """
201
+ lat_start , lat_end = self .tile_bounds .bounds [
202
+ 1 ].y , self .tile_bounds .bounds [0 ].y
203
+ lng_start , lng_end = self .tile_bounds .bounds [
204
+ 1 ].x , self .tile_bounds .bounds [0 ].x
205
+
206
+ # Convert to zoom 0 tile coordinates
207
+ xstart , ystart = self ._latlng_to_tile (lat_start , lng_start , zoom )
208
+ xend , yend = self ._latlng_to_tile (lat_end , lng_end , zoom )
209
+
210
+ # Make sure that the tiles are increasing in order
211
+ xstart , xend = min (xstart , xend ), max (xstart , xend )
212
+ ystart , yend = min (ystart , yend ), max (ystart , yend )
213
+ return (* [pt * 2.0 ** zoom for pt in [xstart , ystart , xend , yend ]],)
214
+
215
+ def _latlng_to_tile (self ,
216
+ lat : float ,
217
+ lng : float ,
218
+ zoom = 0 ) -> Tuple [float , float ]:
219
+ """Converts lat/lng to 3857 tile coordinates
220
+ Formula found here:
221
+ https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#lon.2Flat_to_tile_numbers_2
222
+ """
223
+ scale = 2 ** zoom
224
+ lat_rad = math .radians (lat )
225
+ x = (lng + 180.0 ) / 360.0 * scale
226
+ y = (1.0 - math .asinh (math .tan (lat_rad )) / math .pi ) / 2.0 * scale
227
+ return x , y
228
+
229
+ def _tile_to_pixel (self , tile : float ) -> Tuple [int , int ]:
230
+ """Rounds a tile coordinate and reports the remainder in pixels
231
+ """
232
+ rounded_tile = int (tile )
233
+ remainder = tile - rounded_tile
234
+ pixel_offset = int (self .tile_size * remainder )
235
+ return rounded_tile , pixel_offset
236
+
237
+ def _fetch_image_for_bounds (
238
+ self ,
239
+ x_tile_start : int ,
240
+ y_tile_start : int ,
241
+ x_tile_end : int ,
242
+ y_tile_end : int ,
243
+ zoom : int ,
244
+ ) -> np .ndarray :
245
+ """Fetches the tiles and combines them into a single image
246
+ """
247
+ tiles = {}
248
+ with ThreadPoolExecutor (max_workers = TILE_DOWNLOAD_CONCURRENCY ) as exc :
249
+ for x in range (x_tile_start , x_tile_end + 1 ):
250
+ for y in range (y_tile_start , y_tile_end + 1 ):
251
+ tiles [(x , y )] = exc .submit (self ._fetch_tile , x , y , zoom )
252
+
253
+ rows = []
254
+ for y in range (y_tile_start , y_tile_end + 1 ):
255
+ rows .append (
256
+ np .hstack ([
257
+ tiles [(x , y )].result ()
258
+ for x in range (x_tile_start , x_tile_end + 1 )
259
+ ]))
260
+
261
+ return np .vstack (rows )
262
+
263
+ @retry (delay = 1 , tries = 6 , backoff = 2 , max_delay = 16 )
264
+ def _fetch_tile (self , x : int , y : int , z : int ) -> np .ndarray :
265
+ """
266
+ Fetches the image and returns an np array. If the image cannot be fetched,
267
+ a padding of expected tile size is instead added.
268
+ """
269
+ try :
270
+ data = requests .get (self .tile_layer .url .format (x = x , y = y , z = z ))
271
+ data .raise_for_status ()
272
+ decoded = tf .image .decode_image (data .content , channels = 3 ).numpy ()
273
+ if decoded .shape [:2 ] != (self .tile_size , self .tile_size ):
274
+ logger .warning (
275
+ f"Unexpected tile size { decoded .shape } . Results aren't guarenteed to be correct."
276
+ )
277
+ except :
278
+ logger .warning (
279
+ f"Unable to successfully find tile. for z,x,y: { z } ,{ x } ,{ y } "
280
+ "Padding is being added as a result." )
281
+ decoded = np .zeros (shape = (self .tile_size , self .tile_size , 3 ),
282
+ dtype = np .uint8 )
283
+ return decoded
284
+
285
+ def _crop_to_bounds (
286
+ self ,
287
+ image : np .ndarray ,
288
+ x_px_start : int ,
289
+ y_px_start : int ,
290
+ x_px_end : int ,
291
+ y_px_end : int ,
292
+ ) -> np .ndarray :
293
+ """This function slices off the excess pixels that are outside of the bounds.
294
+ This occurs because only full tiles can be downloaded at a time.
295
+ """
296
+
297
+ def invert_point (pt ):
298
+ # Must have at least 1 pixel for stability.
299
+ pt = max (pt , 1 )
300
+ # All pixel points are relative to a single tile
301
+ # So subtracting the tile size inverts the axis
302
+ pt = pt - self .tile_size
303
+ return pt if pt != 0 else None
304
+
305
+ x_px_end , y_px_end = invert_point (x_px_end ), invert_point (y_px_end )
306
+ return image [y_px_start :y_px_end , x_px_start :x_px_end , :]
307
+
308
+ @validator ('zoom_levels' )
309
+ def validate_zoom_levels (cls , zoom_levels ):
310
+ if zoom_levels [0 ] > zoom_levels [1 ]:
311
+ raise ValueError (
312
+ f"Order of zoom levels should be min, max. Received { zoom_levels } "
313
+ )
314
+ return zoom_levels
108
315
109
316
110
317
#TODO: we will need to update the [data] package to also require pyproj
@@ -114,12 +321,14 @@ class EPSGTransformer(BaseModel):
114
321
115
322
Requires as input a Point object.
116
323
"""
324
+
117
325
class ProjectionTransformer (Transformer ):
118
326
"""Custom class to help represent a Transformer that will play
119
327
nicely with Pydantic.
120
328
121
329
Accepts a PyProj Transformer object.
122
330
"""
331
+
123
332
@classmethod
124
333
def __get_validators__ (cls ):
125
334
yield cls .validate
0 commit comments