From 529d8641a2065a37ef9b4eb893544aeafa37decb Mon Sep 17 00:00:00 2001 From: Emmanuel Mathot Date: Tue, 19 Nov 2024 17:45:58 +0100 Subject: [PATCH 01/12] Render Extension Render extension started during STAC render sprint in SatSummit Lisbon 2024. - listing (or showing to please Vincent) Please contribute to complete the feature to - generate the final XYZ link for rendering following the rules in STAC extensions - add a dedicated endpoint for render XYZ --- item.json | 1 + .../application/titiler/application/main.py | 2 ++ .../extensions/titiler/extensions/__init__.py | 1 + .../extensions/titiler/extensions/render.py | 34 +++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 item.json create mode 100644 src/titiler/extensions/titiler/extensions/render.py diff --git a/item.json b/item.json new file mode 100644 index 000000000..2f8a2b753 --- /dev/null +++ b/item.json @@ -0,0 +1 @@ +{"id":"bs_to_save","bbox":[-120.61338752166166,38.54940283865057,-119.91905658168675,38.90577651328637],"type":"Feature","links":[{"rel":"collection","type":"application/json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity"},{"rel":"parent","type":"application/json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity"},{"rel":"root","type":"application/json","href":"https://openveda.cloud/api/stac/"},{"rel":"self","type":"application/geo+json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity/items/bs_to_save"},{"title":"Map of Item","href":"https://openveda.cloud/api/raster/collections/caldor-fire-burn-severity/items/bs_to_save/map?assets=cog_default&rescale=0%2C5&colormap_name=inferno_r","rel":"preview","type":"text/html"}],"assets":{"cog_default":{"href":"s3://veda-data-store/caldor-fire-burn-severity/bs_to_save.tif","type":"image/tiff; application=geotiff","roles":["data","layer"],"title":"Default COG Layer","proj:bbox":[-120.61338752166166,38.54940283865057,-119.91905658168675,38.90577651328637],"proj:epsg":4326,"proj:wkt2":"GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AXIS[\"Latitude\",NORTH],AXIS[\"Longitude\",EAST],AUTHORITY[\"EPSG\",\"4326\"]]","proj:shape":[1103,2149],"description":"Cloud optimized default layer to display on map","raster:bands":[{"scale":1.0,"nodata":-100.0,"offset":0.0,"sampling":"area","data_type":"float64","histogram":{"max":4.0,"min":1.0,"count":11,"buckets":[10233,0,0,67409,0,0,71518,0,0,24232]},"statistics":{"mean":2.63295307741995,"stddev":0.7936384596443959,"maximum":4.0,"minimum":1.0,"valid_percent":32.191658745247146}}],"proj:geometry":{"type":"Polygon","coordinates":[[[-120.61338752166166,38.54940283865057],[-119.91905658168675,38.54940283865057],[-119.91905658168675,38.90577651328637],[-120.61338752166166,38.90577651328637],[-120.61338752166166,38.54940283865057]]]},"proj:projjson":{"id":{"code":4326,"authority":"EPSG"},"name":"WGS 84","type":"GeographicCRS","datum":{"name":"World Geodetic System 1984","type":"GeodeticReferenceFrame","ellipsoid":{"name":"WGS 84","semi_major_axis":6378137,"inverse_flattening":298.257223563}},"$schema":"https://proj.org/schemas/v0.4/projjson.schema.json","coordinate_system":{"axis":[{"name":"Geodetic latitude","unit":"degree","direction":"north","abbreviation":"Lat"},{"name":"Geodetic longitude","unit":"degree","direction":"east","abbreviation":"Lon"}],"subtype":"ellipsoidal"}},"proj:transform":[0.0003230948999417961,0.0,-120.61338752166166,0.0,-0.00032309489994179427,38.90577651328637,0.0,0.0,1.0]},"rendered_preview":{"title":"Rendered preview","href":"https://openveda.cloud/api/raster/collections/caldor-fire-burn-severity/items/bs_to_save/preview.png?assets=cog_default&rescale=0%2C5&colormap_name=inferno_r","rel":"preview","roles":["overview"],"type":"image/png"}},"geometry":{"type":"Polygon","coordinates":[[[-120.61338752166166,38.54940283865057],[-119.91905658168675,38.54940283865057],[-119.91905658168675,38.90577651328637],[-120.61338752166166,38.90577651328637],[-120.61338752166166,38.54940283865057]]]},"collection":"caldor-fire-burn-severity","properties":{"end_datetime":"2021-10-21T12:00:00+00:00","start_datetime":"2021-08-15T00:00:00+00:00"},"stac_version":"1.0.0","stac_extensions":["https://stac-extensions.github.io/raster/v1.1.0/schema.json","https://stac-extensions.github.io/projection/v1.1.0/schema.json"]} \ No newline at end of file diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index 0d3690d16..367b4b027 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -34,6 +34,7 @@ cogViewerExtension, stacExtension, stacViewerExtension, + renderExtension, ) from titiler.mosaic.errors import MOSAIC_STATUS_CODES from titiler.mosaic.factory import MosaicTilerFactory @@ -122,6 +123,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): router_prefix="/stac", extensions=[ stacViewerExtension(), + renderExtension(), ], ) diff --git a/src/titiler/extensions/titiler/extensions/__init__.py b/src/titiler/extensions/titiler/extensions/__init__.py index 443753a3a..326c24607 100644 --- a/src/titiler/extensions/titiler/extensions/__init__.py +++ b/src/titiler/extensions/titiler/extensions/__init__.py @@ -6,3 +6,4 @@ from .stac import stacExtension # noqa from .viewer import cogViewerExtension, stacViewerExtension # noqa from .wms import wmsExtension # noqa +from .render import renderExtension # noqa \ No newline at end of file diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py new file mode 100644 index 000000000..83e294be4 --- /dev/null +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -0,0 +1,34 @@ +"""render Extension.""" + +from typing import Any, Dict, List, Literal, Optional + +from attrs import define +from fastapi import Depends + +from titiler.core.factory import FactoryExtension, MultiBaseTilerFactory + +import pystac + + +@define +class renderExtension(FactoryExtension): + """Add /stac endpoint to a COG TilerFactory.""" + + ''' + ''' + def register(self, factory: MultiBaseTilerFactory): + """Register endpoint to the tiler factory.""" + + @factory.router.get("/renders", response_model=Dict, name="List STAC renders") + def show_renders( + src_path=Depends(factory.path_dependency) + ): + with factory.reader(src_path) as src: + renders = {} + # if hasattr(src.item, "ext") and src.item.ext.has("renders"): + # renders = src.item.ext.renders + + if src.item.properties.get("renders"): + renders = src.item.properties.get("renders") + + return {"renders": renders} \ No newline at end of file From e20dfef5154a6f65f638aa766ad0d4068a677f2a Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 13:54:01 +0100 Subject: [PATCH 02/12] feat(stac): add render extenstions support --- .../extensions/titiler/extensions/__init__.py | 2 +- .../extensions/titiler/extensions/render.py | 125 +++++++++++++++--- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/titiler/extensions/titiler/extensions/__init__.py b/src/titiler/extensions/titiler/extensions/__init__.py index 326c24607..379b2246c 100644 --- a/src/titiler/extensions/titiler/extensions/__init__.py +++ b/src/titiler/extensions/titiler/extensions/__init__.py @@ -3,7 +3,7 @@ __version__ = "0.19.1" from .cogeo import cogValidateExtension # noqa +from .render import renderExtension # noqa from .stac import stacExtension # noqa from .viewer import cogViewerExtension, stacViewerExtension # noqa from .wms import wmsExtension # noqa -from .render import renderExtension # noqa \ No newline at end of file diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 83e294be4..0661246bf 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -1,34 +1,123 @@ """render Extension.""" -from typing import Any, Dict, List, Literal, Optional +from pprint import pprint +from typing import Annotated, Dict, List, Optional +from urllib.parse import urlencode from attrs import define -from fastapi import Depends +from fastapi import Depends, HTTPException, Path, Request +from fastapi.dependencies.utils import get_dependant, request_params_to_args +from pydantic import BaseModel, RootModel from titiler.core.factory import FactoryExtension, MultiBaseTilerFactory -import pystac + +class RenderItem(BaseModel): + """Render item for stac render extension.""" + + assets: List[str] + title: Optional[str] = None + rescale: Optional[list[Annotated[List[float], 2]]] = None + nodata: Optional[float] = None + colormap_name: Optional[str] = None + colormap: Optional[Dict] = None + color_formula: Optional[str] = None + resampling: Optional[str] = None + expression: Optional[str] = None + minmax_zoom: Optional[Annotated[List[int], 2]] = None + + +RenderItemList = RootModel[Dict[str, RenderItem]] + + +class RenderItemWithLink(RenderItem): + """Same as RenderItem with url and params.""" + + url: str + params: str @define class renderExtension(FactoryExtension): - """Add /stac endpoint to a COG TilerFactory.""" + """Add /renders endpoint to a STAC TilerFactory.""" - ''' - ''' def register(self, factory: MultiBaseTilerFactory): """Register endpoint to the tiler factory.""" - @factory.router.get("/renders", response_model=Dict, name="List STAC renders") - def show_renders( - src_path=Depends(factory.path_dependency) - ): + @factory.router.get( + "/renders", + response_model=RenderItemList, + response_model_exclude_none=True, + name="List STAC renders", + ) + def render_list(src_path=Depends(factory.path_dependency)): with factory.reader(src_path) as src: - renders = {} - # if hasattr(src.item, "ext") and src.item.ext.has("renders"): - # renders = src.item.ext.renders - - if src.item.properties.get("renders"): - renders = src.item.properties.get("renders") - - return {"renders": renders} \ No newline at end of file + renders = src.item.properties.get("renders", {}) + pprint(renders) + return renders + + @factory.router.get( + "/renders/{render_id}", + response_model=RenderItemWithLink, + response_model_exclude_none=True, + name="Show STAC render", + ) + def render( + request: Request, + render_id: str = Path( + description="render id", + ), + src_path=Depends(factory.path_dependency), + ): + with factory.reader(src_path) as src: + renders = src.item.properties.get("renders", {}) + if render_id not in renders: + raise HTTPException(status_code=404, detail="Render not found") + render = renders[render_id] + + url_params = { + "tileMatrixSetId": "{tileMatrixSetId}", + "z": "{z}", + "x": "{x}", + "y": "{y}", + } + + url = factory.url_for(request, "tile", **url_params) + + # List of dependencies a `/tile` URL should validate + # Note: Those dependencies should only require Query() inputs + tile_dependencies = [ + factory.layer_dependency, + factory.dataset_dependency, + # Image rendering Dependencies + factory.rescale_dependency, + factory.color_formula_dependency, + factory.colormap_dependency, + factory.render_dependency, + ] + + final_query = { + "url": src_path, + } + for dependency in tile_dependencies: + dep = get_dependant(path="", call=dependency) + if dep.query_params: + # call the dependency with the query-parameters values + query_values, errors = request_params_to_args( + dep.query_params, render + ) + _ = dependency(**query_values) + final_query.update(query_values) + + return { + **render, + "url": str(url), + "params": urlencode( + { + key: value + for key, value in final_query.items() + if value is not None + }, + doseq=True, + ), + } From c63a482980788f58d2bfb7fb49e6e0b6384bca5b Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 14:07:23 +0100 Subject: [PATCH 03/12] remove unnecessary item.json file --- item.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 item.json diff --git a/item.json b/item.json deleted file mode 100644 index 2f8a2b753..000000000 --- a/item.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bs_to_save","bbox":[-120.61338752166166,38.54940283865057,-119.91905658168675,38.90577651328637],"type":"Feature","links":[{"rel":"collection","type":"application/json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity"},{"rel":"parent","type":"application/json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity"},{"rel":"root","type":"application/json","href":"https://openveda.cloud/api/stac/"},{"rel":"self","type":"application/geo+json","href":"https://openveda.cloud/api/stac/collections/caldor-fire-burn-severity/items/bs_to_save"},{"title":"Map of Item","href":"https://openveda.cloud/api/raster/collections/caldor-fire-burn-severity/items/bs_to_save/map?assets=cog_default&rescale=0%2C5&colormap_name=inferno_r","rel":"preview","type":"text/html"}],"assets":{"cog_default":{"href":"s3://veda-data-store/caldor-fire-burn-severity/bs_to_save.tif","type":"image/tiff; application=geotiff","roles":["data","layer"],"title":"Default COG Layer","proj:bbox":[-120.61338752166166,38.54940283865057,-119.91905658168675,38.90577651328637],"proj:epsg":4326,"proj:wkt2":"GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AXIS[\"Latitude\",NORTH],AXIS[\"Longitude\",EAST],AUTHORITY[\"EPSG\",\"4326\"]]","proj:shape":[1103,2149],"description":"Cloud optimized default layer to display on map","raster:bands":[{"scale":1.0,"nodata":-100.0,"offset":0.0,"sampling":"area","data_type":"float64","histogram":{"max":4.0,"min":1.0,"count":11,"buckets":[10233,0,0,67409,0,0,71518,0,0,24232]},"statistics":{"mean":2.63295307741995,"stddev":0.7936384596443959,"maximum":4.0,"minimum":1.0,"valid_percent":32.191658745247146}}],"proj:geometry":{"type":"Polygon","coordinates":[[[-120.61338752166166,38.54940283865057],[-119.91905658168675,38.54940283865057],[-119.91905658168675,38.90577651328637],[-120.61338752166166,38.90577651328637],[-120.61338752166166,38.54940283865057]]]},"proj:projjson":{"id":{"code":4326,"authority":"EPSG"},"name":"WGS 84","type":"GeographicCRS","datum":{"name":"World Geodetic System 1984","type":"GeodeticReferenceFrame","ellipsoid":{"name":"WGS 84","semi_major_axis":6378137,"inverse_flattening":298.257223563}},"$schema":"https://proj.org/schemas/v0.4/projjson.schema.json","coordinate_system":{"axis":[{"name":"Geodetic latitude","unit":"degree","direction":"north","abbreviation":"Lat"},{"name":"Geodetic longitude","unit":"degree","direction":"east","abbreviation":"Lon"}],"subtype":"ellipsoidal"}},"proj:transform":[0.0003230948999417961,0.0,-120.61338752166166,0.0,-0.00032309489994179427,38.90577651328637,0.0,0.0,1.0]},"rendered_preview":{"title":"Rendered preview","href":"https://openveda.cloud/api/raster/collections/caldor-fire-burn-severity/items/bs_to_save/preview.png?assets=cog_default&rescale=0%2C5&colormap_name=inferno_r","rel":"preview","roles":["overview"],"type":"image/png"}},"geometry":{"type":"Polygon","coordinates":[[[-120.61338752166166,38.54940283865057],[-119.91905658168675,38.54940283865057],[-119.91905658168675,38.90577651328637],[-120.61338752166166,38.90577651328637],[-120.61338752166166,38.54940283865057]]]},"collection":"caldor-fire-burn-severity","properties":{"end_datetime":"2021-10-21T12:00:00+00:00","start_datetime":"2021-08-15T00:00:00+00:00"},"stac_version":"1.0.0","stac_extensions":["https://stac-extensions.github.io/raster/v1.1.0/schema.json","https://stac-extensions.github.io/projection/v1.1.0/schema.json"]} \ No newline at end of file From 3f10defa68a959dd7f91a182e2c7d70f5e7aed0a Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 17:32:46 +0100 Subject: [PATCH 04/12] Rework the response structure to include links. --- .../extensions/titiler/extensions/render.py | 168 ++++++++++++------ 1 file changed, 111 insertions(+), 57 deletions(-) diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 0661246bf..3ffc107f1 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -1,18 +1,20 @@ """render Extension.""" from pprint import pprint -from typing import Annotated, Dict, List, Optional +from typing import Dict, List, Optional from urllib.parse import urlencode from attrs import define from fastapi import Depends, HTTPException, Path, Request from fastapi.dependencies.utils import get_dependant, request_params_to_args -from pydantic import BaseModel, RootModel +from pydantic import BaseModel +from typing_extensions import Annotated from titiler.core.factory import FactoryExtension, MultiBaseTilerFactory +from titiler.core.models.OGC import Link -class RenderItem(BaseModel): +class RenderItem(BaseModel, extra="allow"): """Render item for stac render extension.""" assets: List[str] @@ -27,14 +29,18 @@ class RenderItem(BaseModel): minmax_zoom: Optional[Annotated[List[int], 2]] = None -RenderItemList = RootModel[Dict[str, RenderItem]] +class RenderItemWithLinks(BaseModel): + """Same as RenderItem with url and params.""" + params: RenderItem + links: List[Link] -class RenderItemWithLink(RenderItem): - """Same as RenderItem with url and params.""" - url: str - params: str +class RenderItemList(BaseModel): + """List of Render Items with links.""" + + renders: Dict[str, RenderItemWithLinks] + links: List[Link] @define @@ -44,21 +50,114 @@ class renderExtension(FactoryExtension): def register(self, factory: MultiBaseTilerFactory): """Register endpoint to the tiler factory.""" + def _prepare_query_string(render: Dict, src_path: str) -> str: + # List of dependencies a `/tile` URL should validate + # Note: Those dependencies should only require Query() inputs + tile_dependencies = [ + factory.layer_dependency, + factory.dataset_dependency, + # Image rendering Dependencies + factory.rescale_dependency, + factory.color_formula_dependency, + factory.colormap_dependency, + factory.render_dependency, + ] + + query = {"url": src_path} + for dependency in tile_dependencies: + dep = get_dependant(path="", call=dependency) + if dep.query_params: + # call the dependency with the query-parameters values + query_values, _errors = request_params_to_args( + dep.query_params, render + ) + _ = dependency(**query_values) + query.update(query_values) + return urlencode( + {key: value for key, value in query.items() if value is not None}, + doseq=True, + ) + + def _prepare_render_item( + render_id: str, render: Dict, request: Request, src_path: str + ) -> Dict: + # url = factory.url_for(request, "tile", tileMatrixSetId="{tileMatrixSetId}") + + query_string = _prepare_query_string(render, src_path) + links = [ + { + "href": factory.url_for( + request, + "Show STAC render", + render_id=render_id, + ) + + "?" + + urlencode({"url": src_path}), + "rel": "self", + "type": "application/json", + "title": f"{render_id} render item", + }, + { + "href": factory.url_for( + request, + "tilejson", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"tilejson file for {render_id}", + "templated": True, + }, + { + "href": factory.url_for( + request, + "wmts", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"WMTS service for {render_id}", + "templated": True, + }, + ] + + return { + "params": render, + "links": links, + } + @factory.router.get( "/renders", response_model=RenderItemList, response_model_exclude_none=True, name="List STAC renders", ) - def render_list(src_path=Depends(factory.path_dependency)): + def render_list(request: Request, src_path=Depends(factory.path_dependency)): with factory.reader(src_path) as src: renders = src.item.properties.get("renders", {}) pprint(renders) - return renders + prepared_renders = { + render_id: _prepare_render_item(render_id, render, request, src_path) + for render_id, render in renders.items() + } + pprint(prepared_renders) + return { + "renders": prepared_renders, + "links": [ + { + "href": str(request.url), + "rel": "self", + "type": "application/json", + "title": "List Render Items", + }, + ], + } @factory.router.get( "/renders/{render_id}", - response_model=RenderItemWithLink, + response_model=RenderItemWithLinks, response_model_exclude_none=True, name="Show STAC render", ) @@ -75,49 +174,4 @@ def render( raise HTTPException(status_code=404, detail="Render not found") render = renders[render_id] - url_params = { - "tileMatrixSetId": "{tileMatrixSetId}", - "z": "{z}", - "x": "{x}", - "y": "{y}", - } - - url = factory.url_for(request, "tile", **url_params) - - # List of dependencies a `/tile` URL should validate - # Note: Those dependencies should only require Query() inputs - tile_dependencies = [ - factory.layer_dependency, - factory.dataset_dependency, - # Image rendering Dependencies - factory.rescale_dependency, - factory.color_formula_dependency, - factory.colormap_dependency, - factory.render_dependency, - ] - - final_query = { - "url": src_path, - } - for dependency in tile_dependencies: - dep = get_dependant(path="", call=dependency) - if dep.query_params: - # call the dependency with the query-parameters values - query_values, errors = request_params_to_args( - dep.query_params, render - ) - _ = dependency(**query_values) - final_query.update(query_values) - - return { - **render, - "url": str(url), - "params": urlencode( - { - key: value - for key, value in final_query.items() - if value is not None - }, - doseq=True, - ), - } + return _prepare_render_item(render_id, render, request, src_path) From 99047863f90db038b0c0f1c24215d9ad0ef775ab Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 17:34:22 +0100 Subject: [PATCH 05/12] rename renderExtension -> stacRenderExtension --- src/titiler/application/titiler/application/main.py | 4 ++-- src/titiler/extensions/titiler/extensions/__init__.py | 2 +- src/titiler/extensions/titiler/extensions/render.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index 367b4b027..9649c6fab 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -33,8 +33,8 @@ cogValidateExtension, cogViewerExtension, stacExtension, + stacRenderExtension, stacViewerExtension, - renderExtension, ) from titiler.mosaic.errors import MOSAIC_STATUS_CODES from titiler.mosaic.factory import MosaicTilerFactory @@ -123,7 +123,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): router_prefix="/stac", extensions=[ stacViewerExtension(), - renderExtension(), + stacRenderExtension(), ], ) diff --git a/src/titiler/extensions/titiler/extensions/__init__.py b/src/titiler/extensions/titiler/extensions/__init__.py index 379b2246c..d887e41ad 100644 --- a/src/titiler/extensions/titiler/extensions/__init__.py +++ b/src/titiler/extensions/titiler/extensions/__init__.py @@ -3,7 +3,7 @@ __version__ = "0.19.1" from .cogeo import cogValidateExtension # noqa -from .render import renderExtension # noqa +from .render import stacRenderExtension # noqa from .stac import stacExtension # noqa from .viewer import cogViewerExtension, stacViewerExtension # noqa from .wms import wmsExtension # noqa diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 3ffc107f1..4aee5f8c6 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -44,7 +44,7 @@ class RenderItemList(BaseModel): @define -class renderExtension(FactoryExtension): +class stacRenderExtension(FactoryExtension): """Add /renders endpoint to a STAC TilerFactory.""" def register(self, factory: MultiBaseTilerFactory): From a7121b2f8f935ca179afee2affc0ed3fe8b3a7ea Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 19:02:44 +0100 Subject: [PATCH 06/12] add tests for stacRenderExtension --- .../application/tests/routes/test_stac.py | 2 +- .../tests/fixtures/render_item.json | 323 ++++++++++++++++++ .../extensions/tests/test_stac_render.py | 77 +++++ .../extensions/titiler/extensions/render.py | 3 - 4 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 src/titiler/extensions/tests/fixtures/render_item.json create mode 100644 src/titiler/extensions/tests/test_stac_render.py diff --git a/src/titiler/application/tests/routes/test_stac.py b/src/titiler/application/tests/routes/test_stac.py index 395daff53..7ca2b2186 100644 --- a/src/titiler/application/tests/routes/test_stac.py +++ b/src/titiler/application/tests/routes/test_stac.py @@ -1,4 +1,4 @@ -"""test /COG endpoints.""" +"""test /stac endpoints.""" from typing import Dict diff --git a/src/titiler/extensions/tests/fixtures/render_item.json b/src/titiler/extensions/tests/fixtures/render_item.json new file mode 100644 index 000000000..258ca626f --- /dev/null +++ b/src/titiler/extensions/tests/fixtures/render_item.json @@ -0,0 +1,323 @@ +{ + "type": "Feature", + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json", + "https://stac-extensions.github.io/view/v1.0.0/schema.json", + "https://stac-extensions.github.io/render/v2.0.0/schema.json", + "https://stac-extensions.github.io/virtual-assets/v1.0.0/schema.json", + "https://stac-extensions.github.io/web-map-links/v1.2.0/schema.json" + ], + "id": "LC08_L1TP_044033_20210305_20210312_01_T1", + "properties": { + "gsd": 30, + "platform": "LANDSAT_8", + "instruments": [ + "OLI", + "TIRS" + ], + "eo:cloud_cover": 7.41, + "proj:epsg": 32610, + "view:sun_azimuth": 149.10910644, + "view:sun_elevation": 40.48243563, + "view:off_nadir": 0.001, + "landsat:scene_id": "LC80440332021064LGN00", + "landsat:processing_level": "L1TP", + "landsat:collection_number": "01", + "landsat:collection_category": "T1", + "landsat:cloud_cover_land": 7.4, + "landsat:wrs_path": "44", + "landsat:wrs_row": "33", + "datetime": "2021-03-05T18:45:37.619485Z", + "created": "2021-03-16T01:40:56.703Z", + "updated": "2021-03-16T01:40:56.703Z", + "renders": { + "thumbnail": { + "title": "Thumbnail", + "assets": [ + "B4", + "B3", + "B2" + ], + "rescale": [ + [ + 0, + 150 + ] + ], + "colormap_name": "rainbow", + "resampling": "bilinear", + "bidx": [ + 1 + ], + "width": 1024, + "height": 1024, + "bands": [ + "B4", + "B3", + "B2" + ] + }, + "ndvi": { + "title": "Normalized Difference Vegetation Index", + "assets": [ + "ndvi" + ], + "resampling": "average", + "colormap_name": "ylgn" + } + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -122.49680286164214, + 39.958062660227306 + ], + [ + -120.31547276090922, + 39.578858170656 + ], + [ + -120.82135075676177, + 37.82701417652536 + ], + [ + -122.9993441554352, + 38.2150173967007 + ], + [ + -122.49680286164214, + 39.958062660227306 + ] + ] + ] + }, + "links": [ + { + "href": "https://maps.example.com/xyz/{z}/{x}/{y}.png", + "rel": "xyz", + "type": "image/png", + "title": "RGB composite visualized through a XYZ" + }, + { + "rel": "xyz", + "type": "image/png", + "title": "NDVI", + "href": "https://api.cogeo.xyz/stac/preview.png?url=https://raw.githubusercontent.com/stac-extensions/raster/main/examples/item-landsat8.json&expression=(B5–B4)/(B5+B4)&max_size=512&width=512&resampling_method=average&rescale=-1,1&color_map=ylgn&return_mask=true", + "render": "ndvi" + }, + { + "rel": "collection", + "href": "https://landsat-stac.s3.amazonaws.com/collections/landsat-8-l1.json", + "type": "application/json", + "title": "The full collection" + } + ], + "assets": { + "index": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/index.html", + "type": "application/html", + "title": "HTML Page" + }, + "ANG": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_ANG.txt", + "type": "text/plain", + "title": "ANG Metadata", + "roles": [ + "metadata" + ] + }, + "MTL": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_MTL.txt", + "type": "text/plain", + "title": "MTL Metadata", + "roles": [ + "metadata" + ] + }, + "BQA": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_BQA.TIF", + "type": "image/tiff; application=geotiff", + "title": "Quality Band", + "roles": [ + "quality" + ] + }, + "B1": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B1.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B1", + "common_name": "coastal", + "center_wavelength": 0.48, + "full_width_half_max": 0.02 + } + ] + }, + "B2": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B2.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B2", + "common_name": "blue", + "center_wavelength": 0.44, + "full_width_half_max": 0.06 + } + ] + }, + "B3": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B3.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + } + ] + }, + "B4": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B4.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + } + ] + }, + "B5": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B5.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B5", + "common_name": "nir", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + } + ] + }, + "B6": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B6.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + } + ] + }, + "B7": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B7.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + } + ] + }, + "B8": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B8.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B8", + "common_name": "pan", + "center_wavelength": 0.59, + "full_width_half_max": 0.18 + } + ], + "gsd": 15 + }, + "B9": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B9.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B9", + "common_name": "cirrus", + "center_wavelength": 1.37, + "full_width_half_max": 0.02 + } + ] + }, + "B10": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B10.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + } + ], + "gsd": 100 + }, + "B11": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B11.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B11", + "common_name": "lwir12", + "center_wavelength": 12, + "full_width_half_max": 1 + } + ], + "gsd": 100 + }, + "ndvi": { + "roles": [ + "virtual", + "data", + "index" + ], + "type": "image/vnd.stac.geotiff; cloud-optimized=true", + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1#/assets/NDVI", + "vrt:hrefs": [ + { + "key": "B4", + "href": "#/assets/B4" + }, + { + "key": "B5", + "href": "#/assets/B5" + } + ], + "title": "Normalized Difference Vegetation Index", + "vrt:algorithm": "band_arithmetic", + "vrt:algorithm_opts": { + "expression": "(B05-B04)/(B05+B04)", + "rescale": [ + [ + -1, + 1 + ] + ] + } + } + }, + "bbox": [ + -123.00234, + 37.82405, + -120.31321, + 39.95894 + ], + "collection": "landsat-8-l1-c1" +} \ No newline at end of file diff --git a/src/titiler/extensions/tests/test_stac_render.py b/src/titiler/extensions/tests/test_stac_render.py new file mode 100644 index 000000000..4d374b4d4 --- /dev/null +++ b/src/titiler/extensions/tests/test_stac_render.py @@ -0,0 +1,77 @@ +"""Test STAC Render extension.""" +import os +from urllib.parse import urlencode + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from rio_tiler.io import STACReader + +from titiler.core.factory import MultiBaseTilerFactory +from titiler.extensions.render import stacRenderExtension + +stac_item = os.path.join(os.path.dirname(__file__), "fixtures", "render_item.json") + + +def test_stacExtension(): + """Test stacExtension class.""" + + stac_tiler = MultiBaseTilerFactory(reader=STACReader) + + stac_tiler_plus_stac_render = MultiBaseTilerFactory( + reader=STACReader, extensions=[stacRenderExtension()] + ) + # Check that we added two routes (/renders & /renders/{render_id}) + assert ( + len(stac_tiler_plus_stac_render.router.routes) + == len(stac_tiler.router.routes) + 2 + ) + + app = FastAPI() + app.include_router(stac_tiler_plus_stac_render.router) + with TestClient(app) as client: + response = client.get("/renders", params={"url": stac_item}) + assert response.status_code == 200 + body = response.json() + assert body["renders"] + assert body["links"] + + self_link = body["links"][0] + assert self_link["href"] == response.url + assert self_link["rel"] == "self" + + assert "ndvi" in body["renders"] + assert "thumbnail" in body["renders"] + + expected_params = { + "assets": ["ndvi"], + "colormap_name": "ylgn", + "resampling": "average", + "title": "Normalized Difference Vegetation Index", + } + assert body["renders"]["ndvi"]["params"] == expected_params + + links = body["renders"]["ndvi"]["links"] + assert len(links) == 3 + + stac_item_param = urlencode({"url": stac_item}) + hrefs = {link["href"] for link in links} + expected_hrefs = { + f"http://testserver/renders/ndvi?{stac_item_param}", + f"http://testserver/{{tileMatrixSetId}}/WMTSCapabilities.xml?{stac_item_param}&assets=ndvi&resampling_method=average&colormap_name=ylgn", + f"http://testserver/{{tileMatrixSetId}}/tilejson.json?{stac_item_param}&assets=ndvi&resampling_method=average&colormap_name=ylgn", + } + assert hrefs == expected_hrefs + + response = client.get("/renders/unknown", params={"url": stac_item}) + assert response.status_code == 404 + body = response.json() + assert body == {"detail": "Render not found"} + + response = client.get("/renders/ndvi", params={"url": stac_item}) + assert response.status_code == 200 + body = response.json() + assert body["params"] + assert body["links"] + hrefs = {link["href"] for link in links} + assert hrefs == expected_hrefs + assert body["params"] == expected_params diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 4aee5f8c6..effb8f3f0 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -1,6 +1,5 @@ """render Extension.""" -from pprint import pprint from typing import Dict, List, Optional from urllib.parse import urlencode @@ -137,12 +136,10 @@ def _prepare_render_item( def render_list(request: Request, src_path=Depends(factory.path_dependency)): with factory.reader(src_path) as src: renders = src.item.properties.get("renders", {}) - pprint(renders) prepared_renders = { render_id: _prepare_render_item(render_id, render, request, src_path) for render_id, render in renders.items() } - pprint(prepared_renders) return { "renders": prepared_renders, "links": [ From 3e9995e22f42da7f3e5c83bbbd9dc8e6119e8ea0 Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 19:06:32 +0100 Subject: [PATCH 07/12] add test for extra param --- src/titiler/extensions/tests/fixtures/render_item.json | 3 ++- src/titiler/extensions/tests/test_stac_render.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/titiler/extensions/tests/fixtures/render_item.json b/src/titiler/extensions/tests/fixtures/render_item.json index 258ca626f..5f0b93fa3 100644 --- a/src/titiler/extensions/tests/fixtures/render_item.json +++ b/src/titiler/extensions/tests/fixtures/render_item.json @@ -65,7 +65,8 @@ "ndvi" ], "resampling": "average", - "colormap_name": "ylgn" + "colormap_name": "ylgn", + "extra_param": "that titiler does not know" } } }, diff --git a/src/titiler/extensions/tests/test_stac_render.py b/src/titiler/extensions/tests/test_stac_render.py index 4d374b4d4..49115127b 100644 --- a/src/titiler/extensions/tests/test_stac_render.py +++ b/src/titiler/extensions/tests/test_stac_render.py @@ -47,6 +47,7 @@ def test_stacExtension(): "colormap_name": "ylgn", "resampling": "average", "title": "Normalized Difference Vegetation Index", + "extra_param": "that titiler does not know", } assert body["renders"]["ndvi"]["params"] == expected_params From 767c31df6126ccf9dbe540781e23dd3ad34fbca8 Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 19:41:01 +0100 Subject: [PATCH 08/12] docs and docstrings --- docs/src/advanced/Extensions.md | 3 +++ .../extensions/titiler/extensions/render.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/src/advanced/Extensions.md b/docs/src/advanced/Extensions.md index 531d612b0..dbc495d8d 100644 --- a/docs/src/advanced/Extensions.md +++ b/docs/src/advanced/Extensions.md @@ -55,6 +55,9 @@ class FactoryExtension(metaclass=abc.ABCMeta): - Goal: adds a `/wms` endpoint to support OGC WMS specification (`GetCapabilities` and `GetMap`) +#### stacRenderExtenstion +- Goal: adds a `/render` and `/render/{render_id}` endpoints which return the contents of [STAC render extension](https://github.com/stac-extensions/render) and links to tileset.json and WMTS service + ## How To ### Use extensions diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index effb8f3f0..72f52a4df 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -1,4 +1,8 @@ -"""render Extension.""" +"""STAC Render Extension. + +Implements support for reading and applying Item level render extension. +See: https://github.com/stac-extensions/render +""" from typing import Dict, List, Optional from urllib.parse import urlencode @@ -50,6 +54,11 @@ def register(self, factory: MultiBaseTilerFactory): """Register endpoint to the tiler factory.""" def _prepare_query_string(render: Dict, src_path: str) -> str: + """Prepare render related query params. + + Validates and filters query params that titiler can understand. + If titiler does not support parameter, it will be ignored. + """ # List of dependencies a `/tile` URL should validate # Note: Those dependencies should only require Query() inputs tile_dependencies = [ @@ -80,8 +89,7 @@ def _prepare_query_string(render: Dict, src_path: str) -> str: def _prepare_render_item( render_id: str, render: Dict, request: Request, src_path: str ) -> Dict: - # url = factory.url_for(request, "tile", tileMatrixSetId="{tileMatrixSetId}") - + """Prepare single render item.""" query_string = _prepare_query_string(render, src_path) links = [ { From 46b13ee768715b1fd0b47eb26b96ba4f8f0465a5 Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Tue, 26 Nov 2024 22:52:15 +0100 Subject: [PATCH 09/12] fix typing in python 3.8 --- src/titiler/extensions/titiler/extensions/render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 72f52a4df..87a6d7145 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -22,7 +22,7 @@ class RenderItem(BaseModel, extra="allow"): assets: List[str] title: Optional[str] = None - rescale: Optional[list[Annotated[List[float], 2]]] = None + rescale: Optional[List[Annotated[List[float], 2]]] = None nodata: Optional[float] = None colormap_name: Optional[str] = None colormap: Optional[Dict] = None From 8dbdcf636c59c07cf908d9908ddea556ec7e61c3 Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Mon, 2 Dec 2024 20:30:41 +0100 Subject: [PATCH 10/12] Move out query params validation --- src/titiler/core/tests/test_utils.py | 68 +++++++++++ src/titiler/core/titiler/core/utils.py | 54 ++++++++- .../extensions/tests/test_stac_render.py | 6 +- .../extensions/titiler/extensions/render.py | 113 +++++++++--------- 4 files changed, 182 insertions(+), 59 deletions(-) create mode 100644 src/titiler/core/tests/test_utils.py diff --git a/src/titiler/core/tests/test_utils.py b/src/titiler/core/tests/test_utils.py new file mode 100644 index 000000000..3b2f3b662 --- /dev/null +++ b/src/titiler/core/tests/test_utils.py @@ -0,0 +1,68 @@ +"""Test utils.""" + + +from titiler.core.dependencies import BidxParams +from titiler.core.utils import deserialize_query_params, get_dependency_query_params + + +def test_get_dependency_params(): + """Test dependency filtering from query params.""" + + # invalid + values, err = get_dependency_query_params( + dependency=BidxParams, params={"bidx": ["invalid type"]} + ) + assert values == {} + assert err + assert err == [ + { + "input": "invalid type", + "loc": ( + "query", + "bidx", + 0, + ), + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] + + # not in dep + values, err = get_dependency_query_params( + dependency=BidxParams, params={"not_in_dep": "no error, no value"} + ) + assert values == {"indexes": None} + assert not err + + # valid + values, err = get_dependency_query_params( + dependency=BidxParams, params={"bidx": [1, 2, 3]} + ) + assert values == {"indexes": [1, 2, 3]} + assert not err + + # valid and not in dep + values, err = get_dependency_query_params( + dependency=BidxParams, + params={"bidx": [1, 2, 3], "other param": "to be filtered out"}, + ) + assert values == {"indexes": [1, 2, 3]} + assert not err + + +def test_deserialize_query_params(): + """Test deserialize_query_params.""" + # invalid + res, err = deserialize_query_params( + dependency=BidxParams, params={"bidx": ["invalid type"]} + ) + print(res) + assert res == BidxParams(indexes=None) + assert err + + # valid + res, err = deserialize_query_params( + dependency=BidxParams, params={"not_in_dep": "no error, no value", "bidx": [1]} + ) + assert res == BidxParams(indexes=[1]) + assert not err diff --git a/src/titiler/core/titiler/core/utils.py b/src/titiler/core/titiler/core/utils.py index eaffe7307..52cdc5918 100644 --- a/src/titiler/core/titiler/core/utils.py +++ b/src/titiler/core/titiler/core/utils.py @@ -1,9 +1,12 @@ """titiler.core utilities.""" import warnings -from typing import Any, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, TypeVar, Union +from urllib.parse import urlencode import numpy +from fastapi.datastructures import QueryParams +from fastapi.dependencies.utils import get_dependant, request_params_to_args from rasterio.dtypes import dtype_ranges from rio_tiler.colormap import apply_cmap from rio_tiler.errors import InvalidDatatypeWarning @@ -116,3 +119,52 @@ def render_image( ), output_format.mediatype, ) + + +T = TypeVar("T") + +ValidParams = Dict[str, Any] +Errors = List[Any] + + +def get_dependency_query_params( + dependency: Callable, + params: Dict, +) -> Tuple[ValidParams, Errors]: + """Check QueryParams for Query dependency. + + 1. `get_dependant` is used to get the query-parameters required by the `callable` + 2. we use `request_params_to_args` to construct arguments needed to call the `callable` + 3. we call the `callable` and catch any errors + + Important: We assume the `callable` in not a co-routine. + """ + dep = get_dependant(path="", call=dependency) + return request_params_to_args( + dep.query_params, QueryParams(urlencode(params, doseq=True)) + ) + + +def deserialize_query_params( + dependency: Callable[..., T], params: Dict +) -> Tuple[T, Errors]: + """Deserialize QueryParams for given dependency. + + Parse params as query params and deserialize with dependency. + + Important: We assume the `callable` in not a co-routine. + """ + values, errors = get_dependency_query_params(dependency, params) + return dependency(**values), errors + + +def extract_query_params(dependencies, params) -> Tuple[ValidParams, Errors]: + """Extract query params given list of dependencies.""" + values = {} + errors = [] + for dep in dependencies: + dep_values, dep_errors = deserialize_query_params(dep, params) + if dep_values: + values.update(dep_values) + errors += dep_errors + return values, errors diff --git a/src/titiler/extensions/tests/test_stac_render.py b/src/titiler/extensions/tests/test_stac_render.py index 49115127b..7fa1ce5df 100644 --- a/src/titiler/extensions/tests/test_stac_render.py +++ b/src/titiler/extensions/tests/test_stac_render.py @@ -1,4 +1,5 @@ """Test STAC Render extension.""" + import os from urllib.parse import urlencode @@ -55,11 +56,12 @@ def test_stacExtension(): assert len(links) == 3 stac_item_param = urlencode({"url": stac_item}) + additional_params = "title=Normalized+Difference+Vegetation+Index&assets=ndvi&resampling=average&colormap_name=ylgn&extra_param=that+titiler+does+not+know" hrefs = {link["href"] for link in links} expected_hrefs = { f"http://testserver/renders/ndvi?{stac_item_param}", - f"http://testserver/{{tileMatrixSetId}}/WMTSCapabilities.xml?{stac_item_param}&assets=ndvi&resampling_method=average&colormap_name=ylgn", - f"http://testserver/{{tileMatrixSetId}}/tilejson.json?{stac_item_param}&assets=ndvi&resampling_method=average&colormap_name=ylgn", + f"http://testserver/{{tileMatrixSetId}}/WMTSCapabilities.xml?{stac_item_param}&{additional_params}", + f"http://testserver/{{tileMatrixSetId}}/tilejson.json?{stac_item_param}&{additional_params}", } assert hrefs == expected_hrefs diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 87a6d7145..c967ef244 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -9,12 +9,12 @@ from attrs import define from fastapi import Depends, HTTPException, Path, Request -from fastapi.dependencies.utils import get_dependant, request_params_to_args from pydantic import BaseModel from typing_extensions import Annotated from titiler.core.factory import FactoryExtension, MultiBaseTilerFactory from titiler.core.models.OGC import Link +from titiler.core.utils import extract_query_params class RenderItem(BaseModel, extra="allow"): @@ -53,15 +53,12 @@ class stacRenderExtension(FactoryExtension): def register(self, factory: MultiBaseTilerFactory): """Register endpoint to the tiler factory.""" - def _prepare_query_string(render: Dict, src_path: str) -> str: - """Prepare render related query params. - - Validates and filters query params that titiler can understand. - If titiler does not support parameter, it will be ignored. - """ + def _validate_params(render: Dict) -> bool: + """Validate render related query params.""" # List of dependencies a `/tile` URL should validate # Note: Those dependencies should only require Query() inputs tile_dependencies = [ + factory.reader_dependency, factory.layer_dependency, factory.dataset_dependency, # Image rendering Dependencies @@ -71,64 +68,65 @@ def _prepare_query_string(render: Dict, src_path: str) -> str: factory.render_dependency, ] - query = {"url": src_path} - for dependency in tile_dependencies: - dep = get_dependant(path="", call=dependency) - if dep.query_params: - # call the dependency with the query-parameters values - query_values, _errors = request_params_to_args( - dep.query_params, render - ) - _ = dependency(**query_values) - query.update(query_values) - return urlencode( - {key: value for key, value in query.items() if value is not None}, - doseq=True, - ) + _values, errors = extract_query_params(tile_dependencies, render) + + return errors def _prepare_render_item( render_id: str, render: Dict, request: Request, src_path: str ) -> Dict: """Prepare single render item.""" - query_string = _prepare_query_string(render, src_path) links = [ { "href": factory.url_for( request, - "Show STAC render", + "STAC Renders metadata", render_id=render_id, ) + "?" + urlencode({"url": src_path}), "rel": "self", "type": "application/json", - "title": f"{render_id} render item", - }, - { - "href": factory.url_for( - request, - "tilejson", - tileMatrixSetId="{tileMatrixSetId}", - ) - + "?" - + query_string, - "rel": "tilesets-map", - "title": f"tilejson file for {render_id}", - "templated": True, - }, - { - "href": factory.url_for( - request, - "wmts", - tileMatrixSetId="{tileMatrixSetId}", - ) - + "?" - + query_string, - "rel": "tilesets-map", - "title": f"WMTS service for {render_id}", - "templated": True, - }, + "title": f"STAC Renders metadata for {render_id}", + } ] + errors = _validate_params(render) + + if not errors: + query_string = urlencode( + { + "url": src_path, + **render, + }, + doseq=True, + ) + + links += [ + { + "href": factory.url_for( + request, + "tilejson", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"tilejson file for {render_id}", + "templated": True, + }, + { + "href": factory.url_for( + request, + "wmts", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"WMTS service for {render_id}", + "templated": True, + }, + ] return { "params": render, @@ -139,11 +137,12 @@ def _prepare_render_item( "/renders", response_model=RenderItemList, response_model_exclude_none=True, - name="List STAC renders", + name="List STAC Renders metadata", ) def render_list(request: Request, src_path=Depends(factory.path_dependency)): with factory.reader(src_path) as src: renders = src.item.properties.get("renders", {}) + prepared_renders = { render_id: _prepare_render_item(render_id, render, request, src_path) for render_id, render in renders.items() @@ -155,7 +154,7 @@ def render_list(request: Request, src_path=Depends(factory.path_dependency)): "href": str(request.url), "rel": "self", "type": "application/json", - "title": "List Render Items", + "title": "List STAC Renders metadata", }, ], } @@ -164,7 +163,7 @@ def render_list(request: Request, src_path=Depends(factory.path_dependency)): "/renders/{render_id}", response_model=RenderItemWithLinks, response_model_exclude_none=True, - name="Show STAC render", + name="STAC Renders metadata", ) def render( request: Request, @@ -175,8 +174,10 @@ def render( ): with factory.reader(src_path) as src: renders = src.item.properties.get("renders", {}) - if render_id not in renders: - raise HTTPException(status_code=404, detail="Render not found") - render = renders[render_id] - return _prepare_render_item(render_id, render, request, src_path) + if render_id not in renders: + raise HTTPException(status_code=404, detail="Render not found") + + render = renders[render_id] + + return _prepare_render_item(render_id, render, request, src_path) From 9496130b40b3ebb7d0304869e74844f5dfaceb21 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 20 Dec 2024 10:16:25 +0100 Subject: [PATCH 11/12] edits --- docs/src/advanced/Extensions.md | 3 +- .../extensions/titiler/extensions/render.py | 34 ++++++++----------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/src/advanced/Extensions.md b/docs/src/advanced/Extensions.md index dbc495d8d..bc5f128c2 100644 --- a/docs/src/advanced/Extensions.md +++ b/docs/src/advanced/Extensions.md @@ -56,7 +56,8 @@ class FactoryExtension(metaclass=abc.ABCMeta): - Goal: adds a `/wms` endpoint to support OGC WMS specification (`GetCapabilities` and `GetMap`) #### stacRenderExtenstion -- Goal: adds a `/render` and `/render/{render_id}` endpoints which return the contents of [STAC render extension](https://github.com/stac-extensions/render) and links to tileset.json and WMTS service + +- Goal: adds `/render` and `/render/{render_id}` endpoints which return the contents of [STAC render extension](https://github.com/stac-extensions/render) and links to tileset.json and WMTS service ## How To diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index c967ef244..0bfe09884 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -59,11 +59,11 @@ def _validate_params(render: Dict) -> bool: # Note: Those dependencies should only require Query() inputs tile_dependencies = [ factory.reader_dependency, + factory.tile_dependency, factory.layer_dependency, factory.dataset_dependency, + factory.process_dependency, # Image rendering Dependencies - factory.rescale_dependency, - factory.color_formula_dependency, factory.colormap_dependency, factory.render_dependency, ] @@ -73,7 +73,10 @@ def _validate_params(render: Dict) -> bool: return errors def _prepare_render_item( - render_id: str, render: Dict, request: Request, src_path: str + render_id: str, + render: Dict, + request: Request, + src_path: str, ) -> Dict: """Prepare single render item.""" links = [ @@ -90,16 +93,9 @@ def _prepare_render_item( "title": f"STAC Renders metadata for {render_id}", } ] - errors = _validate_params(render) - if not errors: - query_string = urlencode( - { - "url": src_path, - **render, - }, - doseq=True, - ) + if not _validate_params(render): + query_string = urlencode({"url": src_path, **render}, doseq=True) links += [ { @@ -128,10 +124,7 @@ def _prepare_render_item( }, ] - return { - "params": render, - "links": links, - } + return {"params": render, "links": links} @factory.router.get( "/renders", @@ -178,6 +171,9 @@ def render( if render_id not in renders: raise HTTPException(status_code=404, detail="Render not found") - render = renders[render_id] - - return _prepare_render_item(render_id, render, request, src_path) + return _prepare_render_item( + render_id, + renders[render_id], + request, + src_path, + ) From dc0a57cdf75f6810f67cedd9a8748cc8ff866d37 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 20 Dec 2024 10:21:54 +0100 Subject: [PATCH 12/12] update changelog --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index 02fbe2337..3909cc6cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -26,6 +26,7 @@ ### titiler.extensions * use `factory.render_func` as render function in `wmsExtension` endpoints +* add `stacRenderExtension` which adds two endpoints: `/renders` (lists all renders) and `/renders/` (render metadata and links) (author @alekzvik, https://github.com/developmentseed/titiler/pull/1038) ### Misc