diff --git a/pctiler/pctiler/colormaps/__init__.py b/pctiler/pctiler/colormaps/__init__.py index 5ad33b28..77b2285c 100644 --- a/pctiler/pctiler/colormaps/__init__.py +++ b/pctiler/pctiler/colormaps/__init__.py @@ -2,10 +2,10 @@ from rio_tiler.colormap import cmap from rio_tiler.types import ColorMapType -from titiler.core.dependencies import create_colormap_dependency from .alos_palsar_mosaic import alos_palsar_mosaic_colormaps from .chloris import chloris_colormaps +from .dependencies import create_colormap_dependency from .io_bii import io_bii_colormaps from .jrc import jrc_colormaps from .lidarusgs import lidar_colormaps @@ -40,8 +40,9 @@ # rio-tiler 6.6.1 doesn't support upper case cmap names registered_cmaps = registered_cmaps.register({k.lower(): v}) +all_cmap_keys = list(custom_colormaps.keys()) + list(cmap.data.keys()) +PCColorMapParams = create_colormap_dependency(registered_cmaps, all_cmap_keys) -PCColorMapParams = create_colormap_dependency(registered_cmaps) # Placeholder for non-discrete range colormaps (unsupported) # "hgb-above": { diff --git a/pctiler/pctiler/colormaps/dependencies.py b/pctiler/pctiler/colormaps/dependencies.py new file mode 100644 index 00000000..198585bb --- /dev/null +++ b/pctiler/pctiler/colormaps/dependencies.py @@ -0,0 +1,52 @@ +# flake8: noqa + +import json +from typing import Callable, List, Literal, Optional, Sequence, Union + +from fastapi import HTTPException, Query +from rio_tiler.colormap import ColorMaps, parse_color +from rio_tiler.types import ColorMapType +from typing_extensions import Annotated + + +# Port of titiler.core.dependencies.create_colormap_dependency (0.18.3) which +# support case-sensitive keys in QueryParams and pydantic validation responses +def create_colormap_dependency( + cmap: ColorMaps, original_casing_keys: List[str] +) -> Callable: + """Create Colormap Dependency.""" + + def deps( # type: ignore + colormap_name: Annotated[ # type: ignore + Literal[tuple(original_casing_keys)], + Query(description="Colormap name"), + ] = None, + colormap: Annotated[ + Optional[str], Query(description="JSON encoded custom Colormap") + ] = None, + ) -> Union[ColorMapType, None]: + if colormap_name: + return cmap.get(colormap_name.lower()) + + if colormap: + try: + c = json.loads( + colormap, + object_hook=lambda x: { + int(k): parse_color(v) for k, v in x.items() + }, + ) + + # Make sure to match colormap type + if isinstance(c, Sequence): + c = [(tuple(inter), parse_color(v)) for (inter, v) in c] + + return c + except json.JSONDecodeError as e: + raise HTTPException( + status_code=400, detail="Could not parse the colormap value." + ) from e + + return None + + return deps diff --git a/pctiler/pctiler/endpoints/item.py b/pctiler/pctiler/endpoints/item.py index 5e640372..e82a09fc 100644 --- a/pctiler/pctiler/endpoints/item.py +++ b/pctiler/pctiler/endpoints/item.py @@ -1,11 +1,18 @@ +import logging +from typing import Annotated, Callable, Optional from urllib.parse import quote_plus, urljoin +import fastapi import pystac -from fastapi import Query, Request, Response +import starlette +from fastapi import Body, Depends, HTTPException, Query, Request, Response from fastapi.templating import Jinja2Templates +from geojson_pydantic.features import Feature from html_sanitizer.sanitizer import Sanitizer from starlette.responses import HTMLResponse -from titiler.core.factory import MultiBaseTilerFactory +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.pgstac.dependencies import get_stac_item from pccommon.config import get_render_config @@ -19,6 +26,8 @@ # Try backported to PY<39 `importlib_resources`. from importlib_resources import files as resources_files # type: ignore +logger = logging.getLogger(__name__) + def ItemPathParams( request: Request, @@ -84,3 +93,82 @@ def map( "itemUrl": item_url, }, ) + + +@pc_tile_factory.router.post( + r"/crop", + **img_endpoint_params, +) +@pc_tile_factory.router.post( + r"/crop.{format}", + **img_endpoint_params, +) +@pc_tile_factory.router.post( + r"/crop/{width}x{height}.{format}", + **img_endpoint_params, +) +def geojson_crop( # type: ignore + request: fastapi.Request, + geojson: Annotated[ + Feature, Body(description="GeoJSON Feature.") # noqa: F722,E501 + ], + format: Annotated[ + ImageType, + "Default will be automatically defined if the output image needs a mask (png) or not (jpeg).", # noqa: E501,F722 + ] = None, # type: ignore[assignment] + src_path=Depends(pc_tile_factory.path_dependency), + coord_crs=Depends(CoordCRSParams), + dst_crs=Depends(DstCRSParams), + layer_params=Depends(pc_tile_factory.layer_dependency), + 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), + env=Depends(pc_tile_factory.environment_dependency), +) -> Response: + """Create image from a geojson feature.""" + endpoint = get_endpoint_function( + pc_tile_factory.router, path="/feature", method=request.method + ) + result = endpoint( + geojson=geojson, + format=format, + src_path=src_path, + coord_crs=coord_crs, + dst_crs=dst_crs, + layer_params=layer_params, + 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 + + +def get_endpoint_function( + router: fastapi.APIRouter, path: str, method: str +) -> Callable: + for route in router.routes: + match, _ = route.matches({"type": "http", "path": path, "method": method}) + if match == starlette.routing.Match.FULL: + # The abstract BaseRoute doesn't have a `.endpoint` attribute, + # but all of its subclasses do. + return route.endpoint # type: ignore [attr-defined] + + logger.warning(f"Could not find endpoint. method={method} path={path}") + raise HTTPException(detail="Internal system error", status_code=500) diff --git a/pctiler/tests/endpoints/test_colormaps.py b/pctiler/tests/endpoints/test_colormaps.py index 6507b9e0..5e6a6a1c 100644 --- a/pctiler/tests/endpoints/test_colormaps.py +++ b/pctiler/tests/endpoints/test_colormaps.py @@ -1,7 +1,21 @@ import pytest from httpx import AsyncClient + @pytest.mark.asyncio async def test_get_colormap_uppercasing(client: AsyncClient) -> None: - response = await client.get("/legend/colormap/modis-10A2") + """ + Test mixed casing colormap_name which matches the original key defined + (and used in public render-configs) + """ + params = { + "collection": "naip", + "item": "al_m_3008506_nw_16_060_20191118_20200114", + "assets": "image", + "asset_bidx": "image|1", + "colormap_name": "modis-10A2", + } + response = await client.get( + "/item/tiles/WebMercatorQuad/15/8616/13419@1x", params=params + ) assert response.status_code == 200 diff --git a/pctiler/tests/endpoints/test_pg_item.py b/pctiler/tests/endpoints/test_pg_item.py index 010d27fe..55994bdd 100644 --- a/pctiler/tests/endpoints/test_pg_item.py +++ b/pctiler/tests/endpoints/test_pg_item.py @@ -28,3 +28,38 @@ async def test_item_preview_xss(client: AsyncClient) -> None: # The XSS should be sanitized out of the response assert response_xss.status_code == 200 assert "//" not in response_xss.text + + +@pytest.mark.asyncio +async def test_item_crop(client: AsyncClient) -> None: + """ + Test the legacy /crop endpoint which is provided by pctiler, backed by the + /feature endpoint function from titiler-core + """ + params = { + "collection": "naip", + "item": "al_m_3008506_nw_16_060_20191118_20200114", + "assets": "image", + "asset_bidx": "image|1", + } + geom = { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-85.34600303041255, 30.97430719427659], + [-85.34600303041255, 30.9740750264651], + [-85.34403025022365, 30.9740750264651], + [-85.34403025022365, 30.97430719427659], + [-85.34600303041255, 30.97430719427659], + ] + ], + "type": "Polygon", + }, + } + + resp = await client.post("/item/crop.tif", params=params, json=geom) + + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "image/tiff; application=geotiff"