Skip to content

Commit 41d91a5

Browse files
committed
extend point data extraction to also work vor mbtile layers
1 parent b6843b5 commit 41d91a5

File tree

5 files changed

+106
-7
lines changed

5 files changed

+106
-7
lines changed

server/environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ dependencies:
2727
- rio-tiler
2828
- prometheus-fastapi-instrumentator
2929
- pygeofilter
30+
- mapbox-vector-tile
3031
prefix: /opt/mapbase3/server/conda-env

server/guppy/db/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Config:
3636
class PointResponse(CamelModel):
3737
type: str
3838
layer_name: str
39-
value: Optional[float] = None
39+
value: Optional[float | dict] = None
4040

4141

4242
class StatsResponse(CamelModel):

server/guppy/endpoints/endpoints.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from guppy.db import schemas as s
2727
from guppy.endpoints.endpoint_utils import get_overview_factor, create_stats_response, _extract_area_from_dataset, _extract_shape_mask_from_dataset, _decode, sample_coordinates_window, \
2828
create_quantile_response
29+
from guppy.endpoints.tile_utils import latlon_to_tilexy, get_tile_data, pbf_to_geodataframe
2930

3031
logger = logging.getLogger(__name__)
3132

@@ -292,10 +293,10 @@ def get_multi_line_data_list_for_wkt(db: Session, body: s.MultiLineGeometryListB
292293
return Response(status_code=status.HTTP_404_NOT_FOUND)
293294

294295

295-
def get_point_value_from_raster(db: Session, layer_name: str, x: float, y: float):
296+
def get_point_value_from_layer(db: Session, layer_name: str, x: float, y: float):
296297
t = time.time()
297298
layer_model = db.query(m.LayerMetadata).filter_by(layer_name=layer_name).first()
298-
if layer_model:
299+
if layer_model and not layer_model.is_mbtile:
299300
path = layer_model.file_path
300301
if os.path.exists(path) and x and y:
301302
with rasterio.open(path) as src:
@@ -307,6 +308,19 @@ def get_point_value_from_raster(db: Session, layer_name: str, x: float, y: float
307308
logger.info(f'get_point_value_from_raster 200 {time.time() - t}')
308309
return s.PointResponse(type='point value', layer_name=layer_name, value=None if math.isclose(float(v[0]), nodata) else float(v[0]))
309310
logger.warning(f'file not found {path}')
311+
elif layer_model and layer_model.is_mbtile:
312+
path = layer_model.file_path
313+
if os.path.exists(path) and x and y:
314+
tile_z, tile_x, tile_y = latlon_to_tilexy(x, y, 14)
315+
tile = get_tile_data(layer_name=layer_name, mb_file=path, z=tile_z, x=tile_x, y=tile_y)
316+
tile_df = pbf_to_geodataframe(tile, tile_x, tile_y, tile_z)
317+
# get the value of the point
318+
point = wkt.loads(f'POINT ({x} {y})')
319+
values = tile_df[tile_df.intersects(point)].drop(columns=['geometry'])
320+
result = {'type': 'point value', 'layer_name': layer_name, 'value': values.to_dict(orient='records')[0]}
321+
logger.info(f'get_point_value_from_raster 200 {time.time() - t}')
322+
return result
323+
logger.warning(f'file not found {path}')
310324
logger.info(f'get_point_value_from_raster 204 {time.time() - t}')
311325
return Response(status_code=status.HTTP_204_NO_CONTENT)
312326

server/guppy/endpoints/tile_utils.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@
33
import time
44
from threading import Lock
55

6+
import geopandas as gpd
7+
import mapbox_vector_tile
8+
import mercantile
69
import numpy as np
10+
from shapely.geometry import shape
11+
from shapely.ops import transform
712

813
from guppy.db.db_session import SessionLocal
914
from guppy.db.models import TileStatistics
15+
from guppy.error import create_error
1016

1117
logger = logging.getLogger(__name__)
18+
from typing import Optional
19+
import gzip
20+
import sqlite3
1221

1322

1423
def tile2lonlat(x, y, z):
@@ -33,6 +42,14 @@ def tile2lonlat(x, y, z):
3342
return (lon_left, -lat_bottom, lon_right, -lat_top)
3443

3544

45+
def latlon_to_tilexy(lon, lat, z):
46+
lat_rad = math.radians(lat)
47+
n = 2.0 ** z
48+
xtile = int((lon + 180.0) / 360.0 * n)
49+
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
50+
return z, xtile, ytile
51+
52+
3653
# In-memory request counter, structured by layer_name -> z/x/y -> count
3754
request_counter = {}
3855
request_counter_lock = Lock()
@@ -163,3 +180,70 @@ def get_field_mapping(conn):
163180
cursor.execute("PRAGMA table_info(tiles)")
164181
columns = cursor.fetchall()
165182
return {col[1]: col[1] for col in columns} # Simple mapping of name to name
183+
184+
185+
def get_tile_data(layer_name: str, mb_file: str, z: int, x: int, y: int) -> Optional[bytes]:
186+
"""
187+
Args:
188+
layer_name: The name of the layer for which the tile data is being retrieved.
189+
mb_file: The path to the MBTiles file from which the tile data is being retrieved.
190+
z: The zoom level of the tile.
191+
x: The X coordinate of the tile.
192+
y: The Y coordinate of the tile.
193+
194+
Returns:
195+
Optional[bytes]: The tile data as bytes if found, or None if no tile data exists for the given parameters.
196+
197+
Raises:
198+
HTTPException: If there is an error retrieving the tile data from the MBTiles file.
199+
200+
"""
201+
# Flip Y coordinate because MBTiles grid is TMS (bottom-left origin)
202+
y = (1 << z) - 1 - y
203+
logger.info(f"Getting tile for layer {layer_name} at zoom {z}, x {x}, y {y}")
204+
try:
205+
uri = f'file:{mb_file}?mode=ro'
206+
with sqlite3.connect(uri, uri=True) as conn:
207+
cursor = conn.cursor()
208+
cursor.execute("SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?", (z, x, y))
209+
tile = cursor.fetchone()
210+
if tile:
211+
return gzip.decompress(bytes(tile[0]))
212+
else:
213+
return None
214+
except Exception as e:
215+
create_error(code=404, message=str(e))
216+
217+
218+
def pbf_to_geodataframe(pbf_data, x, y, z):
219+
"""
220+
Converts PBF data to a GeoDataFrame.
221+
222+
:param pbf_data: The PBF data to be decoded.
223+
:param x: The x-coordinate of the tile.
224+
:param y: The y-coordinate of the tile.
225+
:param z: The zoom level of the tile.
226+
:return: A GeoDataFrame containing the decoded PBF data in GeoJSON format.
227+
"""
228+
# Decode PBF data
229+
decoded_data = mapbox_vector_tile.decode(pbf_data)
230+
tile_bounds = mercantile.bounds(x, y, z)
231+
# Collect features and convert them to GeoJSON format
232+
features = []
233+
for layer_name, layer in decoded_data.items():
234+
for feature in layer['features']:
235+
geom = shape(feature['geometry'])
236+
237+
def scale_translate(x, y, bounds=tile_bounds, tile_dim=4096):
238+
# Adjust for the flipped tiles by inverting the y-axis calculation
239+
lon = (x / tile_dim) * (bounds.east - bounds.west) + bounds.west
240+
lat = (y / tile_dim) * (bounds.north - bounds.south) + bounds.south
241+
return lon, lat
242+
243+
geom_transformed = transform(scale_translate, geom)
244+
properties = feature['properties']
245+
properties["featureId"] = feature['id']
246+
features.append({'type': 'Feature', 'geometry': geom_transformed, 'properties': properties})
247+
248+
gdf = gpd.GeoDataFrame.from_features(features, crs='EPSG:4326')
249+
return gdf

server/guppy/routes/data_router.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
from fastapi.responses import ORJSONResponse
33
from sqlalchemy.orm import Session
44

5-
from guppy.endpoints import endpoints
65
from guppy.config import config as cfg
76
from guppy.db import schemas as s
87
from guppy.db.dependencies import get_db
8+
from guppy.endpoints import endpoints
99

1010
router = APIRouter(
1111
prefix=f"{cfg.deploy.path}/layers",
@@ -38,9 +38,9 @@ def get_multi_line_data_list_for_wkt(body: s.MultiLineGeometryListBody, db: Sess
3838
return endpoints.get_multi_line_data_list_for_wkt(db=db, body=body)
3939

4040

41-
@router.get("/{layer_name}/point", response_model=s.PointResponse, description="Get point value for a given coordinate from raster within a layer.")
42-
def get_point_value_from_raster(layer_name: str, x: float, y: float, db: Session = Depends(get_db)):
43-
return endpoints.get_point_value_from_raster(db=db, layer_name=layer_name, x=x, y=y)
41+
@router.get("/{layer_name}/point", response_model=s.PointResponse, description="Get point value for a given coordinate (in 4326) from a layer.")
42+
def get_point_value_from_layer(layer_name: str, x: float, y: float, db: Session = Depends(get_db)):
43+
return endpoints.get_point_value_from_layer(db=db, layer_name=layer_name, x=x, y=y)
4444

4545

4646
@router.post("/{layer_name}/object", response_class=ORJSONResponse, description="Get object list for a given line wkt geometry within a layer.")

0 commit comments

Comments
 (0)