Skip to content

Commit f85bc0d

Browse files
committed
update to raster generating method
1 parent bbef909 commit f85bc0d

File tree

1 file changed

+224
-15
lines changed

1 file changed

+224
-15
lines changed

labelbox/data/annotation_types/data/tiled_image.py

Lines changed: 224 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
1+
import math
2+
import logging
13
from 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
58
import numpy as np
9+
import tensorflow as tf
10+
from retry import retry
611
from pyproj import Transformer
12+
from pydantic import BaseModel, validator, conlist
13+
from pydantic.class_validators import root_validator
714

815
from ..geometry import Point
916
from .base_data import BaseData
1017
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__)
1131

1232

1333
class EPSG(Enum):
@@ -26,7 +46,7 @@ class EPSG(Enum):
2646
class 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

5289
class 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

Comments
 (0)