Skip to content

Commit

Permalink
Fix cmap and add /crop
Browse files Browse the repository at this point in the history
  • Loading branch information
mmcfarland committed Jul 16, 2024
1 parent 08e64a0 commit e86229e
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 5 deletions.
5 changes: 3 additions & 2 deletions pctiler/pctiler/colormaps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions pctiler/pctiler/colormaps/dependencies.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 90 additions & 2 deletions pctiler/pctiler/endpoints/item.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
16 changes: 15 additions & 1 deletion pctiler/tests/endpoints/test_colormaps.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions pctiler/tests/endpoints/test_pg_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "//</script>" 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"

0 comments on commit e86229e

Please sign in to comment.