diff --git a/pctiler/pctiler/endpoints/item.py b/pctiler/pctiler/endpoints/item.py index 9af89b8a..e823b208 100644 --- a/pctiler/pctiler/endpoints/item.py +++ b/pctiler/pctiler/endpoints/item.py @@ -3,9 +3,11 @@ from typing import Annotated, Optional from urllib.parse import quote_plus, urljoin +import jinja2 import fastapi import pystac -from fastapi import Body, Depends, Query, Request, Response +from pydantic import Field +from fastapi import Body, Depends, Query, Request, Response, Path from fastapi.templating import Jinja2Templates from geojson_pydantic.features import Feature from html_sanitizer.sanitizer import Sanitizer @@ -13,6 +15,7 @@ from titiler.core.dependencies import CoordCRSParams, DstCRSParams from titiler.core.factory import MultiBaseTilerFactory, img_endpoint_params from titiler.core.resources.enums import ImageType +from titiler.core.models.mapbox import TileJSON from titiler.pgstac.dependencies import get_stac_item from pccommon.config import get_render_config @@ -22,12 +25,6 @@ from pctiler.endpoints.dependencies import get_endpoint_function from pctiler.reader import ItemSTACReader, ReaderParams -try: - from importlib.resources import files as resources_files # type: ignore -except ImportError: - # Try backported to PY<39 `importlib_resources`. - from importlib_resources import files as resources_files # type: ignore - logger = logging.getLogger(__name__) @@ -79,11 +76,10 @@ async def _fetch() -> dict: return pystac.Item.from_dict(_item) -# TODO: mypy fails in python 3.9, we need to find a proper way to do this -templates = Jinja2Templates( - directory=str(resources_files(__package__) / "templates") # type: ignore +jinja2_env = jinja2.Environment( + loader=jinja2.ChoiceLoader([jinja2.PackageLoader(__package__, "templates")]) ) - +templates = Jinja2Templates(env=jinja2_env) pc_tile_factory = MultiBaseTilerFactory( reader=ItemSTACReader, @@ -135,17 +131,20 @@ def map( }, ) - +# crop/feature endpoint compat with titiler<0.15 (`/crop` was renamed `/feature`) @pc_tile_factory.router.post( r"/crop", + operation_id=f"{self.operation_prefix}postDataForGeoJSONCrop", **img_endpoint_params, ) @pc_tile_factory.router.post( r"/crop.{format}", + operation_id=f"{self.operation_prefix}postDataForGeoJSONWithFormatCrop", **img_endpoint_params, ) @pc_tile_factory.router.post( r"/crop/{width}x{height}.{format}", + operation_id=f"{self.operation_prefix}postDataForGeoJSONWithSizesAndFormatCrop", **img_endpoint_params, ) def geojson_crop( # type: ignore @@ -155,7 +154,9 @@ def geojson_crop( # type: ignore ], format: Annotated[ ImageType, - "Default will be automatically defined if the output image needs a mask (png) or not (jpeg).", # noqa: E501,F722 + Field( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg)." + ), ] = None, # type: ignore[assignment] src_path=Depends(pc_tile_factory.path_dependency), coord_crs=Depends(CoordCRSParams), @@ -164,14 +165,6 @@ def geojson_crop( # type: ignore dataset_params=Depends(pc_tile_factory.dataset_dependency), image_params=Depends(pc_tile_factory.img_part_dependency), post_process=Depends(pc_tile_factory.process_dependency), - rescale=Depends(pc_tile_factory.rescale_dependency), - color_formula: Annotated[ - Optional[str], - Query( - title="Color Formula", # noqa: F722 - description="rio-color formula (info: https://github.com/mapbox/rio-color)", # noqa: E501,F722 - ), - ] = None, colormap=Depends(pc_tile_factory.colormap_dependency), render_params=Depends(pc_tile_factory.render_dependency), reader_params=Depends(pc_tile_factory.reader_dependency), @@ -191,11 +184,159 @@ def geojson_crop( # type: ignore dataset_params=dataset_params, image_params=image_params, post_process=post_process, - rescale=rescale, - color_formula=color_formula, colormap=colormap, render_params=render_params, reader_params=reader_params, env=env, ) return result + + +# /tiles endpoint compat with titiler<0.15, Optional `tileMatrixSetId` +@pc_tile_factory.router.get( + "/tiles/{z}/{x}/{y}", + operation_id=f"{pc_tile_factory.operation_prefix}getWebMercatorQuadTile", + **img_endpoint_params, +) +@pc_tile_factory.router.get( + "/tiles/{z}/{x}/{y}.{format}", + operation_id=f"{pc_tile_factory.operation_prefix}getWebMercatorQuadTileWithFormat", + **img_endpoint_params, +) +@pc_tile_factory.router.get( + "/tiles/{z}/{x}/{y}@{scale}x", + operation_id=f"{pc_tile_factory.operation_prefix}getWebMercatorQuadTileWithScale", + **img_endpoint_params, +) +@pc_tile_factory.router.get( + "/tiles/{z}/{x}/{y}@{scale}x.{format}", + operation_id=f"{pc_tile_factory.operation_prefix}getWebMercatorQuadTileWithFormatAndScale", + **img_endpoint_params, +) +def tile_compat( + request: fastapi.Request, + z: Annotated[ + int, + Path( + description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.", + ), + ], + x: Annotated[ + int, + Path( + description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.", + ), + ], + y: Annotated[ + int, + Path( + description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.", + ), + ], + scale: Annotated[ + int, + Field( + gt=0, le=4, description="Tile size scale. 1=256x256, 2=512x512..." + ), + ] = 1, + format: Annotated[ + ImageType, + Field( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg)." + ), + ] = None, + src_path=Depends(pc_tile_factory.path_dependency), + reader_params=Depends(pc_tile_factory.reader_dependency), + tile_params=Depends(pc_tile_factory.tile_dependency), + layer_params=Depends(pc_tile_factory.layer_dependency), + dataset_params=Depends(pc_tile_factory.dataset_dependency), + post_process=Depends(pc_tile_factory.process_dependency), + colormap=Depends(pc_tile_factory.colormap_dependency), + render_params=Depends(pc_tile_factory.render_dependency), + env=Depends(pc_tile_factory.environment_dependency), +) -> Response: + """tiles endpoints compat.""" + endpoint = get_endpoint_function( + pc_tile_factory.router, path="/tiles/{tileMatrixSetId}/{z}/{x}/{y}", method=request.method + ) + result = endpoint( + z=z, + x=x, + y=y, + tileMatrixSetId="WebMercatorQuad", + scale=scale, + format=format, + src_path=src_path, + reader_params=reader_params, + tile_params=tile_params, + layer_params=layer_params, + dataset_params=dataset_params, + post_process=post_process, + colormap=colormap, + render_params=render_params, + env=env, + ) + return result + + +# /tilejson.json endpoint compat with titiler<0.15, Optional `tileMatrixSetId` +@pc_tile_factory.router.get( + "/tilejson.json", + response_model=TileJSON, + responses={200: {"description": "Return a tilejson"}}, + response_model_exclude_none=True, + operation_id=f"{pc_tile_factory.operation_prefix}getWebMercatorQuadTileJSON", +) +def tilejson_compat( + request: fastapi.Request, + tile_format: Annotated[ + Optional[ImageType], + Query( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg).", + ), + ] = None, + tile_scale: Annotated[ + int, + Query( + gt=0, lt=4, description="Tile size scale. 1=256x256, 2=512x512..." + ), + ] = 1, + minzoom: Annotated[ + Optional[int], + Query(description="Overwrite default minzoom."), + ] = None, + maxzoom: Annotated[ + Optional[int], + Query(description="Overwrite default maxzoom."), + ] = None, + src_path=Depends(pc_tile_factory.path_dependency), + reader_params=Depends(pc_tile_factory.reader_dependency), + tile_params=Depends(pc_tile_factory.tile_dependency), + layer_params=Depends(pc_tile_factory.layer_dependency), + dataset_params=Depends(pc_tile_factory.dataset_dependency), + post_process=Depends(pc_tile_factory.process_dependency), + colormap=Depends(pc_tile_factory.colormap_dependency), + render_params=Depends(pc_tile_factory.render_dependency), + env=Depends(pc_tile_factory.environment_dependency), +) -> Response: + """tilejson endpoint compat.""" + endpoint = get_endpoint_function( + pc_tile_factory.router, path="/{tileMatrixSetId}/tilejson.json", method=request.method + ) + result = endpoint( + tileMatrixSetId="WebMercatorQuad", + tile_format=tile_format, + tile_scale=tile_scale, + minzoom=minzoom, + maxzoom=maxzoom, + src_path=src_path, + reader_params=reader_params, + tile_params=tile_params, + layer_params=layer_params, + dataset_params=dataset_params, + post_process=post_process, + colormap=colormap, + render_params=render_params, + env=env, + ) + return result diff --git a/pctiler/pctiler/endpoints/pg_mosaic.py b/pctiler/pctiler/endpoints/pg_mosaic.py index f8e692fb..3a3e0860 100644 --- a/pctiler/pctiler/endpoints/pg_mosaic.py +++ b/pctiler/pctiler/endpoints/pg_mosaic.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Annotated, List, Literal, Optional -from fastapi import APIRouter, Depends, FastAPI, Query, Request, Response +from fastapi import APIRouter, Depends, FastAPI, Query, Request, Response, Path from fastapi.responses import ORJSONResponse from psycopg_pool import ConnectionPool from pydantic import Field @@ -9,7 +9,7 @@ from titiler.core.dependencies import ColorFormulaParams from titiler.core.factory import img_endpoint_params from titiler.core.resources.enums import ImageType -from titiler.pgstac.dependencies import SearchIdParams, TmsTileParams +from titiler.pgstac.dependencies import SearchIdParams from titiler.pgstac.extensions import searchInfoExtension from titiler.pgstac.factory import MosaicTilerFactory @@ -40,7 +40,7 @@ def __init__(self, request: Request): pgstac_mosaic_factory = MosaicTilerFactory( - reader=PGSTACBackend, + backend=PGSTACBackend, path_dependency=SearchIdParams, colormap_dependency=PCColorMapParams, layer_dependency=AssetsBidxExprParams, @@ -86,7 +86,7 @@ def mosaic_info( legacy_mosaic_router = APIRouter() - +# Compat with titiler-pgstac<0.3.0, (`/tiles/{search_id}/...` was renamed `/{search_id}/tiles/...`) @legacy_mosaic_router.get("/tiles/{search_id}/{z}/{x}/{y}", **img_endpoint_params) @legacy_mosaic_router.get( "/tiles/{search_id}/{z}/{x}/{y}.{format}", **img_endpoint_params @@ -115,57 +115,74 @@ def mosaic_info( def tile_routes( # type: ignore request: Request, search_id=Depends(pgstac_mosaic_factory.path_dependency), - tile=Depends(TmsTileParams), + z: Annotated[ + int, + Path( + description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.", + ), + ], + x: Annotated[ + int, + Path( + description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.", + ), + ], + y: Annotated[ + int, + Path( + description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.", + ), + ], tileMatrixSetId: Annotated[ # type: ignore Literal[tuple(pgstac_mosaic_factory.supported_tms.list())], - f"Identifier selecting one of the TileMatrixSetId supported (default: '{pgstac_mosaic_factory.default_tms}')", # noqa: E501,F722 - ] = pgstac_mosaic_factory.default_tms, + f"Identifier selecting one of the TileMatrixSetId supported (default: 'WebMercatorQuad')", # noqa: E501,F722 + ] = "WebMercatorQuad", scale: Annotated[ # type: ignore Optional[Annotated[int, Field(gt=0, le=4)]], "Tile size scale. 1=256x256, 2=512x512...", # noqa: E501,F722 ] = None, format: Annotated[ Optional[ImageType], - "Default will be automatically defined if the output image needs a mask (png) or not (jpeg).", # noqa: E501,F722 + Field( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg)." + ), ] = None, + backend_params=Depends(pgstac_mosaic_factory.backend_dependency), + reader_params=Depends(pgstac_mosaic_factory.reader_dependency), + assets_accessor_params=Depends(pgstac_mosaic_factory.assets_accessor_dependency), layer_params=Depends(pgstac_mosaic_factory.layer_dependency), dataset_params=Depends(pgstac_mosaic_factory.dataset_dependency), pixel_selection=Depends(pgstac_mosaic_factory.pixel_selection_dependency), tile_params=Depends(pgstac_mosaic_factory.tile_dependency), post_process=Depends(pgstac_mosaic_factory.process_dependency), - rescale=Depends(pgstac_mosaic_factory.rescale_dependency), - color_formula=Depends(ColorFormulaParams), colormap=Depends(pgstac_mosaic_factory.colormap_dependency), render_params=Depends(pgstac_mosaic_factory.render_dependency), - pgstac_params=Depends(pgstac_mosaic_factory.pgstac_dependency), - backend_params=Depends(pgstac_mosaic_factory.backend_dependency), - reader_params=Depends(pgstac_mosaic_factory.reader_dependency), env=Depends(pgstac_mosaic_factory.environment_dependency), ) -> Response: """Create map tile.""" endpoint = get_endpoint_function( pgstac_mosaic_factory.router, - path="/tiles/{z}/{x}/{y}", + path="/tiles/{tileMatrixSetId}/{z}/{x}/{y}", method=request.method, ) result = endpoint( search_id=search_id, - tile=tile, + z=z, + x=x, + y=y, tileMatrixSetId=tileMatrixSetId, scale=scale, format=format, - tile_params=tile_params, + backend_params=backend_params, + reader_params=reader_params, + assets_accessor_params=assets_accessor_params, layer_params=layer_params, dataset_params=dataset_params, pixel_selection=pixel_selection, + tile_params=tile_params, post_process=post_process, - rescale=rescale, - color_formula=color_formula, colormap=colormap, render_params=render_params, - pgstac_params=pgstac_params, - backend_params=backend_params, - reader_params=reader_params, env=env, ) return result diff --git a/pctiler/pctiler/main.py b/pctiler/pctiler/main.py index f82ff17d..d5f769c8 100755 --- a/pctiler/pctiler/main.py +++ b/pctiler/pctiler/main.py @@ -92,12 +92,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: pg_mosaic.pgstac_mosaic_factory.dataset_dependency, pg_mosaic.pgstac_mosaic_factory.pixel_selection_dependency, pg_mosaic.pgstac_mosaic_factory.process_dependency, - pg_mosaic.pgstac_mosaic_factory.rescale_dependency, - pg_mosaic.pgstac_mosaic_factory.colormap_dependency, pg_mosaic.pgstac_mosaic_factory.render_dependency, + pg_mosaic.pgstac_mosaic_factory.assets_accessor_dependency, pg_mosaic.pgstac_mosaic_factory.reader_dependency, pg_mosaic.pgstac_mosaic_factory.backend_dependency, - pg_mosaic.pgstac_mosaic_factory.pgstac_dependency, ], tags=["PgSTAC Mosaic endpoints"], ) diff --git a/pctiler/pctiler/reader.py b/pctiler/pctiler/reader.py index 9313aad1..6cf25937 100644 --- a/pctiler/pctiler/reader.py +++ b/pctiler/pctiler/reader.py @@ -1,3 +1,4 @@ +import warnings import logging import time from dataclasses import dataclass, field @@ -13,10 +14,11 @@ from rio_tiler.models import ImageData from rio_tiler.mosaic import mosaic_reader from rio_tiler.types import AssetInfo +from rio_tiler.io.stac import STAC_ALTERNATE_KEY from starlette.requests import Request from titiler.core.dependencies import DefaultDependency -from titiler.pgstac import mosaic as pgstac_mosaic -from titiler.pgstac.reader import PgSTACReader +from titiler.pgstac import backend as pgstac_mosaic +from titiler.pgstac.reader import PgSTACReader, SimpleSTACReader from titiler.pgstac.settings import CacheSettings from pccommon.cdn import BlobCDN @@ -48,22 +50,77 @@ class ItemSTACReader(PgSTACReader): request: Optional[Request] = attr.ib(default=None) def _get_asset_info(self, asset: str) -> AssetInfo: - """return asset's url.""" - info = super()._get_asset_info(asset) - asset_url = BlobCDN.transform_if_available(info["url"]) + """Validate asset names and return asset's info. + + Args: + asset (str): STAC asset name. + + Returns: + AssetInfo: STAC asset info. + + """ + asset, vrt_options = self._parse_vrt_asset(asset) + if asset not in self.assets: + raise InvalidAssetName( + f"'{asset}' is not valid, should be one of {self.assets}" + ) + + asset_info = self.item.assets[asset] + extras = asset_info.extra_fields + + info = AssetInfo( + url=asset_info.get_absolute_href() or asset_info.href, + metadata=extras if not vrt_options else None, + ) + + if STAC_ALTERNATE_KEY and extras.get("alternate"): + if alternate := extras["alternate"].get(STAC_ALTERNATE_KEY): + info["url"] = alternate["href"] - if self.input.collection_id: - render_config = get_render_config(self.input.collection_id) + asset_url = BlobCDN.transform_if_available(info["url"]) + if self.item.collection_id: + render_config = get_render_config(self.item.collection_id) if render_config and render_config.requires_token: asset_url = pc.sign(asset_url) info["url"] = asset_url + + if asset_info.media_type: + info["media_type"] = asset_info.media_type + + # https://github.com/stac-extensions/file + if head := extras.get("file:header_size"): + info["env"] = {"GDAL_INGESTED_BYTES_AT_OPEN": head} + + # https://github.com/stac-extensions/raster + if extras.get("raster:bands") and not vrt_options: + bands = extras.get("raster:bands") + stats = [ + (b["statistics"]["minimum"], b["statistics"]["maximum"]) + for b in bands + if {"minimum", "maximum"}.issubset(b.get("statistics", {})) + ] + # check that stats data are all double and make warning if not + if ( + stats + and all(isinstance(v, (int, float)) for stat in stats for v in stat) + and len(stats) == len(bands) + ): + info["dataset_statistics"] = stats + else: + warnings.warn( + "Some statistics data in STAC are invalid, they will be ignored." + ) + + if vrt_options: + info["url"] = f"vrt://{info['url']}?{vrt_options}" + return info @attr.s -class MosaicSTACReader(pgstac_mosaic.CustomSTACReader): - """Custom version of titiler.pgstac.mosaic.CustomSTACReader).""" +class MosaicSTACReader(SimpleSTACReader): + """Custom version of titiler.pgstac.reader.SimpleSTACReader.""" # We make request an optional attribute to avoid re-writing # the whole list of attribute @@ -79,24 +136,43 @@ def _get_asset_info(self, asset: str) -> AssetInfo: str: STAC asset href. """ + asset, vrt_options = self._parse_vrt_asset(asset) if asset not in self.assets: - raise InvalidAssetName(f"{asset} is not valid") + raise InvalidAssetName( + f"{asset} is not valid. Should be one of {self.assets}" + ) - asset_url = BlobCDN.transform_if_available(self.input["assets"][asset]["href"]) + asset_info = self.input["assets"][asset] + info = AssetInfo( + url=asset_info["href"], + env={}, + ) - collection = self.input.get("collection", None) - if collection: + asset_url = BlobCDN.transform_if_available(info["url"]) + if collection := self.input.get("collection", None): render_config = get_render_config(collection) if render_config and render_config.requires_token: asset_url = pc.sign(asset_url) - info = AssetInfo(url=asset_url) - if "file:header_size" in self.input["assets"][asset]: - info["env"] = { - "GDAL_INGESTED_BYTES_AT_OPEN": self.input["assets"][asset][ - "file:header_size" - ] - } + info["url"] = asset_url + + if media_type := asset_info.get("type"): + info["media_type"] = media_type + + if header_size := asset_info.get("file:header_size"): + info["env"]["GDAL_INGESTED_BYTES_AT_OPEN"] = header_size + + if bands := asset_info.get("raster:bands"): + stats = [ + (b["statistics"]["minimum"], b["statistics"]["maximum"]) + for b in bands + if {"minimum", "maximum"}.issubset(b.get("statistics", {})) + ] + if len(stats) == len(bands): + info["dataset_statistics"] = stats + + if vrt_options: + info["url"] = f"vrt://{info['url']}?{vrt_options}" return info @@ -200,7 +276,7 @@ def _reader( ) as src_dst: return src_dst.tile(x, y, z, **kwargs) - tile = mosaic_reader( + img, used_assets = mosaic_reader( mosaic_assets, _reader, tile_x, @@ -223,4 +299,4 @@ def _reader( ), ) - return tile + return img, [x["id"] for x in used_assets] diff --git a/pctiler/pctiler/version.py b/pctiler/pctiler/version.py index 3dc1f76b..d3ec452c 100644 --- a/pctiler/pctiler/version.py +++ b/pctiler/pctiler/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/pctiler/pyproject.toml b/pctiler/pyproject.toml index a776bf18..16cabd14 100644 --- a/pctiler/pyproject.toml +++ b/pctiler/pyproject.toml @@ -7,12 +7,11 @@ name = "pctiler" dynamic = ["version"] description = "Planetary Computer API - Tiler." license = { text = "MIT" } -requires-python = ">=3.7" +requires-python = ">=3.9" dependencies = [ - "fastapi-slim==0.111.0", - "geojson-pydantic==1.1.0", + "fastapi==0.115.12", + "geojson-pydantic==2.0.0", "idna>=3.7.0", - "importlib_resources>=1.1.0;python_version<'3.9'", "jinja2==3.1.5", "matplotlib==3.9.0", "orjson==3.10.4", @@ -21,11 +20,11 @@ dependencies = [ "psycopg[binary,pool]", "pydantic>=2.7,<2.8", "pystac==1.10.1", - "rasterio==1.3.10", + "rasterio==1.4.3", "requests==2.32.3", - "titiler.core==0.18.3", - "titiler.mosaic==0.18.3", - "titiler.pgstac==1.3.0", + "titiler.core==0.22.1", + "titiler.mosaic==0.22.1", + "titiler.pgstac==1.8.0", ] [project.optional-dependencies]