Skip to content

Commit

Permalink
add GeoJSON Feature /crop POST endpoint (#339)
Browse files Browse the repository at this point in the history
* add POST crop endpoint using geojson feature

* update docs

* update changelog

* fix failing tests

* Update docs/endpoints/stac.md
  • Loading branch information
vincentsarago authored Jul 23, 2021
1 parent 9a8d579 commit a4f4a95
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 17 deletions.
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Release Notes

## Unrelease

### titiler.core

* add `/crop` POST endpoint to return an image from a GeoJSON feature (https://github.com/developmentseed/titiler/pull/339)


## 0.3.3 (2021-06-29)

### titiler.core
Expand Down
15 changes: 9 additions & 6 deletions docs/advanced/tiler_factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ app.include_router(cog.router, tags=["Cloud Optimized GeoTIFF"])
| `GET` | `/[{TileMatrixSetId}]/tilejson.json` | JSON | return a Mapbox TileJSON document
| `GET` | `/{TileMatrixSetId}/WMTSCapabilities.xml` | XML | return OGC WMTS Get Capabilities
| `GET` | `/point/{lon},{lat}` | JSON | return pixel value from a dataset
| `GET` | `/preview[.{format}]` | image/bin | **Optional** - create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | **Optional** - create an image from part of a dataset
| `GET` | `/preview[.{format}]` | image/bin | create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset
| `POST` | `/crop[/{width}x{height}][.{format}]` | image/bin | create an image from a geojson feature

### `titiler.core.factory.MultiBaseTilerFactory`

Expand Down Expand Up @@ -52,8 +53,9 @@ app.include_router(cog.router, tags=["STAC"])
| `GET` | `/[{TileMatrixSetId}]/tilejson.json` | JSON | return a Mapbox TileJSON document
| `GET` | `/{TileMatrixSetId}/WMTSCapabilities.xml` | XML | return OGC WMTS Get Capabilities
| `GET` | `/point/{lon},{lat}` | JSON | return pixel value from a dataset
| `GET` | `/preview[.{format}]` | image/bin | **Optional** - create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | **Optional** - create an image from part of a dataset
| `GET` | `/preview[.{format}]` | image/bin | create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset
| `POST` | `/crop[/{width}x{height}][.{format}]` | image/bin | create an image from a geojson feature

### `titiler.core.factory.MultiBandTilerFactory`

Expand Down Expand Up @@ -87,8 +89,9 @@ app.include_router(cog.router, tags=["Landsat"])
| `GET` | `/[{TileMatrixSetId}]/tilejson.json` | JSON | return a Mapbox TileJSON document
| `GET` | `/{TileMatrixSetId}/WMTSCapabilities.xml` | XML | return OGC WMTS Get Capabilities
| `GET` | `/point/{lon},{lat}` | JSON | return pixel value from a dataset
| `GET` | `/preview[.{format}]` | image/bin | **Optional** - create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | **Optional** - create an image from part of a dataset
| `GET` | `/preview[.{format}]` | image/bin | create a preview image from a dataset
| `GET` | `/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset
| `POST` | `/crop[/{width}x{height}][.{format}]` | image/bin | create an image from a geojson feature


### `titiler.mosaic.factory.MosaicTilerFactory`
Expand Down
34 changes: 32 additions & 2 deletions docs/endpoints/cog.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Read Info/Metadata and create Web map Tiles from a **single** COG. The `cog` rou
| `GET` | `/cog/point/{lon},{lat}` | JSON | return pixel value from a dataset
| `GET` | `/cog/preview[.{format}]` | image/bin | create a preview image from a dataset
| `GET` | `/cog/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset
| `POST` | `/cog/crop[/{width}x{height}][].{format}]` | image/bin | create an image from a geojson covering a dataset
| `GET` | `/cog/validate` | JSON | validate a COG and return dataset info
| `GET` | `/cog/viewer` | HTML | demo webpage

Expand Down Expand Up @@ -106,13 +107,42 @@ Example:
- **colormap**: JSON encoded custom Colormap. OPTIONAL
- **resampling_method**: rasterio resampling method. Default is `nearest`.

Note: if `height` and `width` are provided `max_size` will be ignored.

Example:

- `https://myendpoint/cog/crop/0,0,10,10.png?url=https://somewhere.com/mycog.tif`
- `https://myendpoint/cog/crop/0,0,10,10.png?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&colormap_name=cfastie`


`:endpoint:/cog/crop[/{width}x{height}][].{format}] - [POST]`

- Body:
- **feature**: A valida GeoJSON feature (Polygon or MultiPolygon)

- PathParams:
- **height**: Force output image height. OPTIONAL
- **width**: Force output image width. OPTIONAL
- **format**: Output image format, default is set to None and will be either JPEG or PNG depending on masked value. OPTIONAL

- QueryParams:
- **url**: Cloud Optimized GeoTIFF URL. **REQUIRED**
- **bidx**: Comma (',') delimited band indexes. OPTIONAL
- **expression**: rio-tiler's band math expression (e.g B1/B2). OPTIONAL
- **nodata**: Overwrite internal Nodata value. OPTIONAL
- **max_size**: Max image size, default is 1024. OPTIONAL
- **rescale**: Comma (',') delimited Min,Max bounds. OPTIONAL
- **color_formula**: rio-color formula. OPTIONAL
- **colormap_name**: rio-tiler color map name. OPTIONAL
- **colormap**: JSON encoded custom Colormap. OPTIONAL
- **resampling_method**: rasterio resampling method. Default is `nearest`.

Example:

- `https://myendpoint/cog/crop?url=https://somewhere.com/mycog.tif`
- `https://myendpoint/cog/crop.png?url=https://somewhere.com/mycog.tif`
- `https://myendpoint/cog/crop/100x100.png?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&colormap_name=cfastie`

Note: if `height` and `width` are provided `max_size` will be ignored.

### Point

`:endpoint:/cog/point/{lon},{lat}`
Expand Down
39 changes: 36 additions & 3 deletions docs/endpoints/stac.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Read Info/Metadata and create Web map Tiles from a **single** STAC Item. The `s
| `GET` | `/stac/point/{lon},{lat}` | JSON | return pixel value from a dataset
| `GET` | `/stac/preview[.{format}]` | image/bin | create a preview image from a dataset
| `GET` | `/stac/crop/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset
| `POST` | `/stac/crop[/{width}x{height}][].{format}]` | image/bin | create an image from a geojson covering a STAC Item
| `GET` | `/stac/viewer` | HTML | demo webpage (Not created by the factory)

## Description
Expand Down Expand Up @@ -115,13 +116,46 @@ Example:

***assets** OR **expression** is required

Note: if `height` and `width` are provided `max_size` will be ignored.

Example:

- `https://myendpoint/stac/crop/0,0,10,10.png?url=https://somewhere.com/item.json&assets=B01`
- `https://myendpoint/stac/crop/0,0,10,10.png?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&colormap_name=cfastie`


`:endpoint:/stac/crop[/{width}x{height}][].{format}] - [POST]`

- Body:
- **feature**: A valida GeoJSON feature (Polygon or MultiPolygon)

- PathParams:
- **height**: Force output image height. OPTIONAL
- **width**: Force output image width. OPTIONAL
- **format**: Output image format, default is set to None and will be either JPEG or PNG depending on masked value. OPTIONAL

- QueryParams:
- **url**: STAC Item URL. **REQUIRED**
- **assets**: Comma (',') delimited asset names. OPTIONAL*
- **expression**: rio-tiler's band math expression (e.g B1/B2). OPTIONAL
- **bidx**: Comma (',') delimited band indexes. OPTIONAL
- **nodata**: Overwrite internal Nodata value. OPTIONAL

- **max_size**: Max image size, default is 1024. OPTIONAL
- **rescale**: Comma (',') delimited Min,Max bounds. OPTIONAL
- **color_formula**: rio-color formula. OPTIONAL
- **colormap_name**: rio-tiler color map name. OPTIONAL
- **colormap**: JSON encoded custom Colormap. OPTIONAL
- **resampling_method**: rasterio resampling method. Default is `nearest`.

***assets** OR **expression** is required

Example:

- `https://myendpoint/stac/crop?url=https://somewhere.com/item.json&assets=B01`
- `https://myendpoint/stac/crop.png?url=https://somewhere.com/item.json&assets=B01`
- `https://myendpoint/stac/crop/100x100.png?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&colormap_name=cfastie`

Note: if `height` and `width` are provided `max_size` will be ignored.

### Point

`:endpoint:/cog/point/{lon},{lat}`
Expand Down Expand Up @@ -241,4 +275,3 @@ Demonstration viewer added to the router created by the factory (https://github.
Example:

- `https://myendpoint/stac/viewer?url=https://somewhere.com/item.json`

4 changes: 2 additions & 2 deletions src/titiler/application/tests/routes/test_demos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@

def test_cog_viewer(app):
"""Test COG Viewer."""
response = app.get("/cog/viewer")
response = app.get("/cog/viewer", headers={"accept-encoding": "gzip"})
assert response.status_code == 200
assert response.headers["content-type"] == "text/html; charset=utf-8"
assert response.headers["content-encoding"] == "gzip"


def test_stac_viewer(app):
"""Test STAC Viewer."""
response = app.get("/stac/viewer")
response = app.get("/stac/viewer", headers={"accept-encoding": "gzip"})
assert response.status_code == 200
assert response.headers["content-type"] == "text/html; charset=utf-8"
assert response.headers["content-encoding"] == "gzip"
47 changes: 44 additions & 3 deletions src/titiler/core/tests/test_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
def test_TilerFactory():
"""Test TilerFactory class."""
cog = TilerFactory()
assert len(cog.router.routes) == 21
assert len(cog.router.routes) == 24
assert cog.tms_dependency == TMSParams

cog = TilerFactory(router_prefix="something", tms_dependency=WebMercatorTMSParams)
Expand Down Expand Up @@ -272,14 +272,55 @@ def test_TilerFactory():
assert meta["dtype"] == "int16"
assert meta["count"] == 1

feature = json.dumps(
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-59.23828124999999, 74.16408546675687],
[-59.83154296874999, 73.15680773175981],
[-58.73291015624999, 72.88087095711504],
[-56.62353515625, 73.06104462497655],
[-55.17333984375, 73.41588526207096],
[-55.2392578125, 74.09799577518739],
[-56.88720703125, 74.2895142503942],
[-57.23876953124999, 74.30735341486248],
[-59.23828124999999, 74.16408546675687],
]
],
},
}
)

response = client.post(f"/crop?url={DATA_DIR}/cog.tif", data=feature)
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"

response = client.post(f"/crop.tif?url={DATA_DIR}/cog.tif", data=feature)
assert response.status_code == 200
assert response.headers["content-type"] == "image/tiff; application=geotiff"
meta = parse_img(response.content)
assert meta["dtype"] == "uint16"
assert meta["count"] == 2

response = client.post(f"/crop/100x100.jpeg?url={DATA_DIR}/cog.tif", data=feature)
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
meta = parse_img(response.content)
assert meta["width"] == 100
assert meta["height"] == 100


@patch("rio_tiler.io.cogeo.rasterio")
def test_MultiBaseTilerFactory(rio):
"""test MultiBaseTilerFactory."""
rio.open = mock_rasterio_open

stac = MultiBaseTilerFactory(reader=STACReader)
assert len(stac.router.routes) == 22
assert len(stac.router.routes) == 25

app = FastAPI()
app.include_router(stac.router)
Expand Down Expand Up @@ -359,7 +400,7 @@ def test_MultiBandTilerFactory():
"""test MultiBandTilerFactory."""

bands = MultiBandTilerFactory(reader=BandFileReader)
assert len(bands.router.routes) == 22
assert len(bands.router.routes) == 25

app = FastAPI()
app.include_router(bands.router)
Expand Down
68 changes: 67 additions & 1 deletion src/titiler/core/titiler/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from titiler.core.resources.responses import GeoJSONResponse, XMLResponse
from titiler.core.utils import Timer, bbox_to_feature

from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Body, Depends, Path, Query

from starlette.requests import Request
from starlette.responses import Response
Expand Down Expand Up @@ -706,6 +706,72 @@ def part(

return Response(content, media_type=format.mediatype, headers=headers)

@self.router.post(
r"/crop", **img_endpoint_params,
)
@self.router.post(
r"/crop.{format}", **img_endpoint_params,
)
@self.router.post(
r"/crop/{width}x{height}.{format}", **img_endpoint_params,
)
def geojson_crop(
feature: Feature = Body(..., descriptiom="GeoJSON Feature."),
format: ImageType = Query(
None, description="Output image type. Default is auto."
),
src_path=Depends(self.path_dependency),
layer_params=Depends(self.layer_dependency),
image_params=Depends(self.img_dependency),
dataset_params=Depends(self.dataset_dependency),
render_params=Depends(self.render_dependency),
colormap=Depends(self.colormap_dependency),
kwargs: Dict = Depends(self.additional_dependency),
):
"""Create image from a geojson feature."""
timings = []
headers: Dict[str, str] = {}

with Timer() as t:
with rasterio.Env(**self.gdal_config):
with self.reader(src_path, **self.reader_options) as src_dst:
data = src_dst.feature(
feature.dict(exclude_none=True),
**layer_params.kwargs,
**image_params.kwargs,
**dataset_params.kwargs,
**kwargs,
)
dst_colormap = getattr(src_dst, "colormap", None)
timings.append(("dataread", round(t.elapsed * 1000, 2)))

with Timer() as t:
image = data.post_process(
in_range=render_params.rescale_range,
color_formula=render_params.color_formula,
)
timings.append(("postprocess", round(t.elapsed * 1000, 2)))

if not format:
format = ImageType.jpeg if data.mask.all() else ImageType.png

with Timer() as t:
content = image.render(
add_mask=render_params.return_mask,
img_format=format.driver,
colormap=colormap or dst_colormap,
**format.profile,
**render_params.kwargs,
)
timings.append(("format", round(t.elapsed * 1000, 2)))

if OptionalHeader.server_timing in self.optional_headers:
headers["Server-Timing"] = ", ".join(
[f"{name};dur={time}" for (name, time) in timings]
)

return Response(content, media_type=format.mediatype, headers=headers)


@dataclass
class MultiBaseTilerFactory(TilerFactory):
Expand Down

0 comments on commit a4f4a95

Please sign in to comment.