1+ import math
2+ import logging
13from enum import Enum
2- from typing import Optional , List
4+ from typing import Optional , List , Tuple
5+ from concurrent .futures import ThreadPoolExecutor
36
4- from pydantic import BaseModel , validator , conlist
7+ import requests
58import numpy as np
9+ import tensorflow as tf
10+ from retry import retry
611from pyproj import Transformer
12+ from pydantic import BaseModel , validator , conlist
13+ from pydantic .class_validators import root_validator
714
815from ..geometry import Point
916from .base_data import BaseData
1017from .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__ )
1131
1232
1333class EPSG (Enum ):
@@ -26,7 +46,7 @@ class EPSG(Enum):
2646class TiledBounds (BaseModel ):
2747 """ Bounds for a tiled image asset related to the relevant epsg.
2848
29- Bounds should be Point objects
49+ Bounds should be Point objects. Currently, we support bounds in EPSG 4326.
3050
3151 If version of asset is 2, these should be [[lat,lng],[lat,lng]]
3252 If version of asset is 1, these should be [[lng,lat]],[lng,lat]]
@@ -40,14 +60,31 @@ class TiledBounds(BaseModel):
4060 bounds : List [Point ]
4161
4262 @validator ('bounds' )
43- def validate_bounds (cls , bounds ):
63+ def validate_bounds_not_equal (cls , bounds ):
4464 first_bound = bounds [0 ]
4565 second_bound = bounds [1 ]
4666
4767 if first_bound == second_bound :
4868 raise AssertionError (f"Bounds cannot be equal, contains { bounds } " )
4969 return bounds
5070
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+
5188
5289class TileLayer (BaseModel ):
5390 """ Url that contains the tile layer. Must be in the format:
@@ -83,28 +120,198 @@ class TiledImageData(BaseData):
83120 max_native_zoom: int = None
84121 tile_size: Optional[int]
85122 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])
87128 """
88129 tile_layer : TileLayer
89130 tile_bounds : TiledBounds
90131 alternative_layers : List [TileLayer ] = None
91132 zoom_levels : conlist (item_type = int , min_items = 2 , max_items = 2 )
92133 max_native_zoom : int = None
93- tile_size : Optional [int ]
134+ tile_size : Optional [int ] = TMS_TILE_SIZE
94135 version : int = 2
95136
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 )
103171
104- #TODO
105172 @property
106173 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
108315
109316
110317#TODO: we will need to update the [data] package to also require pyproj
@@ -114,12 +321,14 @@ class EPSGTransformer(BaseModel):
114321
115322 Requires as input a Point object.
116323 """
324+
117325 class ProjectionTransformer (Transformer ):
118326 """Custom class to help represent a Transformer that will play
119327 nicely with Pydantic.
120328
121329 Accepts a PyProj Transformer object.
122330 """
331+
123332 @classmethod
124333 def __get_validators__ (cls ):
125334 yield cls .validate
0 commit comments