├── .circleci └── config.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin ├── package.sh └── tests.sh ├── codecov.yml ├── docker-compose.yml ├── lambda_tiler ├── __init__.py ├── handler.py ├── ogc.py ├── scripts │ ├── __init__.py │ └── cli.py └── viewer.py ├── package.json ├── serverless.yml ├── setup.py ├── tests ├── fixtures │ └── cog.tif └── test_api.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | common: &common 3 | working_directory: ~/lambda-tiler 4 | steps: 5 | - checkout 6 | - run: 7 | name: install dependencies 8 | command: pip install tox codecov pre-commit --user 9 | - run: 10 | name: run tox 11 | command: ~/.local/bin/tox 12 | - run: 13 | name: run pre-commit 14 | command: | 15 | if [[ "$CIRCLE_JOB" == "python-3.6" ]]; then 16 | ~/.local/bin/pre-commit run --all-files 17 | fi 18 | - run: 19 | name: upload coverage report 20 | command: | 21 | if [[ "$UPLOAD_COVERAGE" == 1 ]]; then 22 | ~/.local/bin/coverage xml 23 | ~/.local/bin/codecov 24 | fi 25 | when: always 26 | 27 | jobs: 28 | "python-3.6": 29 | <<: *common 30 | docker: 31 | - image: circleci/python:3.6.5 32 | environment: 33 | - TOXENV=py36 34 | - UPLOAD_COVERAGE=1 35 | 36 | "python-3.7": 37 | <<: *common 38 | docker: 39 | - image: circleci/python:3.7.2 40 | environment: 41 | - TOXENV=py37 42 | 43 | workflows: 44 | version: 2 45 | build_and_test: 46 | jobs: 47 | - "python-3.6" 48 | - "python-3.7" 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | node_modules 104 | package.zip 105 | .serverless 106 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | repos: 3 | - 4 | repo: 'https://github.com/ambv/black' 5 | # 18.6b1 6 | rev: ed50737290662f6ef4016a7ea44da78ee1eff1e2 7 | hooks: 8 | - id: black 9 | args: ['--safe'] 10 | language_version: python3.6 11 | - 12 | repo: 'https://github.com/pre-commit/pre-commit-hooks' 13 | # v1.3.0 14 | rev: a6209d8d4f97a09b61855ea3f1fb250f55147b8b 15 | hooks: 16 | - id: flake8 17 | language_version: python3.6 18 | args: [ 19 | # E501 let black handle all line length decisions 20 | # W503 black conflicts with "line break before operator" rule 21 | # E203 black conflicts with "whitespace before ':'" rule 22 | '--ignore=E501,W503,E203'] 23 | - 24 | repo: 'https://github.com/chewse/pre-commit-mirrors-pydocstyle' 25 | # 2.1.1 26 | rev: 22d3ccf6cf91ffce3b16caa946c155778f0cb20f 27 | hooks: 28 | - id: pydocstyle 29 | language_version: python3.6 30 | args: [ 31 | # Check for docstring presence only 32 | '--select=D1', 33 | # Don't require docstrings for tests 34 | '--match=(?!test).*\.py'] 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM remotepixel/amazonlinux:gdal3.0-py3.7-cogeo 2 | 3 | WORKDIR /tmp 4 | 5 | ENV PYTHONUSERBASE=/var/task 6 | 7 | COPY setup.py setup.py 8 | COPY lambda_tiler/ lambda_tiler/ 9 | 10 | RUN pip install . --user 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Vincent Sarago 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | 3 | package: 4 | docker build --tag lambdatiler:latest . 5 | docker run \ 6 | --name lambdatiler \ 7 | -w /tmp \ 8 | --volume $(shell pwd)/bin:/tmp/bin \ 9 | --volume $(shell pwd)/:/local \ 10 | -itd lambdatiler:latest \ 11 | bash 12 | docker exec -it lambdatiler bash '/tmp/bin/package.sh' 13 | docker stop lambdatiler 14 | docker rm lambdatiler -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambda-tiler 2 | 3 | [![CircleCI](https://circleci.com/gh/vincentsarago/lambda-tiler.svg?style=svg)](https://circleci.com/gh/vincentsarago/lambda-tiler) 4 | [![codecov](https://codecov.io/gh/vincentsarago/lambda-tiler/branch/master/graph/badge.svg)](https://codecov.io/gh/vincentsarago/lambda-tiler) 5 | 6 | #### AWS Lambda + rio-tiler to serve tiles from any web hosted files 7 | 8 | ![image_preview](https://user-images.githubusercontent.com/10407788/56755674-0fbad500-675e-11e9-8996-f0fae4a1a30c.jpeg) 9 | 10 | **lambda-tiler** is a simple serverless (AWS Lambda function) application that serves Map Tiles dynamically created from COGs hosted remotely (s3, http, ...) 11 | 12 | # Deploy 13 | 14 | #### Requirement 15 | - AWS Account 16 | - Docker (+ docker-compose) 17 | - node + npm (serverless) 18 | 19 | 20 | #### Create the package 21 | 22 | ```bash 23 | # Build Amazon linux AMI docker container + Install Python modules + create package 24 | $ git clone https://github.com/vincentsarago/lambda-tiler.git 25 | $ cd lambda-tiler/ 26 | 27 | $ docker-compose build 28 | $ docker-compose run --rm package 29 | 30 | # Tests 31 | $ docker-compose run --rm tests 32 | ``` 33 | 34 | Note: Docker image from https://github.com/RemotePixel/amazonlinux 35 | 36 | #### Deploy to AWS 37 | 38 | ```bash 39 | #configure serverless (https://serverless.com/framework/docs/providers/aws/guide/credentials/) 40 | npm install 41 | sls deploy 42 | ``` 43 | 44 | # API 45 | 46 | ## Viewer 47 | `/viewer` - GET 48 | 49 | A web viewer that allows you to pan & zoom the COG. 50 | 51 | Inputs: 52 | - **url** (required, str): mosaic id 53 | - **kwargs** (optional): Other querystring parameters will be forwarded to the tile url. 54 | 55 | Outputs: 56 | - **html** (text/html) 57 | 58 | `$ curl {your-endpoint}/viewer?url=https://any-file.on/the-internet.tif` 59 | 60 | ## TileJSON (2.1.0) 61 | `/tilejson.json` - GET 62 | 63 | Inputs: 64 | - **url** (required): mosaic definition url 65 | - **tile_format** (optional, str): output tile format (default: "png") 66 | - **kwargs** (in querytring): Other querystring parameters will be forwarded to the tile url 67 | 68 | Outputs: 69 | - **tileJSON** (application/json) 70 | 71 | `$ curl https://{endpoint-url}/tilejson.json?url=https://any-file.on/the-internet.tif` 72 | 73 | ```json 74 | { 75 | "bounds": [...], 76 | "center": [lon, lat], 77 | "minzoom": 18, 78 | "maxzoom": 22, 79 | "name": "the-internet.tif", 80 | "tilejson": "2.1.0", 81 | "tiles": [...] , 82 | } 83 | ``` 84 | 85 | ## Bounds 86 | 87 | Inputs: 88 | - **url** (required): mosaic definition url 89 | 90 | Outputs: 91 | - **metadata** (application/json) 92 | 93 | `$ curl https://{endpoint-url}/bounds?url=https://any-file.on/the-internet.tif` 94 | 95 | ```json 96 | { 97 | "url": "https://any-file.on/the-internet.tif", 98 | "bounds": [...] 99 | } 100 | ``` 101 | 102 | ## Metadata 103 | 104 | `/metadata` - GET 105 | 106 | Inputs: 107 | - **url** (required, str): dataset url 108 | - **pmin** (optional, str): min percentile (default: 2). 109 | - **pmax** (optional, str): max percentile (default: 98). 110 | - **nodata** (optional, str): Custom nodata value if not preset in dataset. 111 | - **indexes** (optional, str): dataset band indexes 112 | - **overview_level** (optional, str): Select the overview level to fetch for statistic calculation 113 | - **max_size** (optional, str): Maximum size of dataset to retrieve for overview level automatic calculation 114 | - **histogram_bins** (optional, str, default:20): number of equal-width histogram bins 115 | - **histogram_range** (optional, str): histogram min/max 116 | 117 | Outputs: 118 | - **metadata** (application/json) 119 | 120 | 121 | `$ curl https://{endpoint-url}/metadata?url=s3://url=https://any-file.on/the-internet.tif` 122 | 123 | ```json 124 | { 125 | "address": "s3://myfile.tif", 126 | "bbox": [...], 127 | "band_descriptions": [(1, "red"), (2, "green"), (3, "blue"), (4, "nir")], 128 | "statistics": { 129 | "1": { 130 | "pc": [38, 147], 131 | "min": 20, 132 | "max": 180, 133 | "std": 28.123562304138662, 134 | "histogram": [ 135 | [...], 136 | [...] 137 | ] 138 | }, 139 | ... 140 | } 141 | } 142 | ``` 143 | 144 | 145 | ## Tiles 146 | `/tiles/{z}/{x}/{y}` - GET 147 | 148 | `/tiles/{z}/{x}/{y}.{ext}` - GET 149 | 150 | `/tiles/{z}/{x}/{y}@{scale}x` - GET 151 | 152 | `/tiles/{z}/{x}/{y}@{scale}x.{ext}` - GET 153 | 154 | Inputs: 155 | - **z**: Mercator tile zoom value 156 | - **x**: Mercator tile x value 157 | - **y**: Mercator tile y value 158 | - **ext**: image format (e.g `jpg`) 159 | - **scale** (optional, int): tile scale (default: 1) 160 | - **url** (required, str): dataset url 161 | - **indexes** (optional, str): dataset band indexes (default: dataset indexes) 162 | - **expr** (optional, str): dataset expression 163 | - **nodata** (optional, str): Custom nodata value if not preset in dataset (default: None) 164 | - **rescale** (optional, str): min/max for data rescaling (default: None) 165 | - **color_formula** (optional, str): rio-color formula (default: None) 166 | - **color_map** (optional, str): rio-tiler colormap (default: None) 167 | 168 | Outputs: 169 | - **image body** (image/jpeg) 170 | 171 | `$ curl {your-endpoint}/tiles/7/10/10.png?url=https://any-file.on/the-internet.tif` 172 | 173 | Note: 174 | - **expr** and **indexes** cannot be passed used together 175 | - if no **ext** passed, lambda-tiler will choose the best format (jpg or png) depending on mask (use png for tile with nodata) 176 | 177 | ## Example 178 | 179 | A web viewer that allows you to pan & zoom on a sample tiff. 180 | 181 | Inputs: None 182 | 183 | Outputs: 184 | - **html** (text/html) 185 | 186 | `$ curl {your-endpoint}/example` -------------------------------------------------------------------------------- /bin/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "-----------------------" 3 | echo "Creating lambda package" 4 | echo "-----------------------" 5 | echo "Remove uncompiled python scripts" 6 | # Leave module precompiles for faster Lambda startup 7 | cd ${PYTHONUSERBASE}/lib/python3.7/site-packages/ 8 | find . -type f -name '*.pyc' | while read f; do n=$(echo $f | sed 's/__pycache__\///' | sed 's/.cpython-[2-3][0-9]//'); cp $f $n; done; 9 | find . -type d -a -name '__pycache__' -print0 | xargs -0 rm -rf 10 | find . -type f -a -name '*.py' -print0 | xargs -0 rm -f 11 | 12 | echo "Create archive" 13 | zip -r9q /tmp/package.zip * 14 | 15 | cp /tmp/package.zip /local/package.zip -------------------------------------------------------------------------------- /bin/tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "lambda-tiler Version: " && python3.6 -c 'from lambda_tiler import __version__ as lt_version; print(lt_version)' 3 | 4 | echo "/bounds" 5 | python3 -c 'from lambda_tiler.handler import APP; resp = APP({"path": "/bounds", "queryStringParameters": {"url": "https://oin-hotosm.s3.amazonaws.com/5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif"}, "pathParameters": "null", "requestContext": "null", "httpMethod": "GET", "headers": {}}, None); print("OK") if resp["statusCode"] == 200 else print("NOK")' 6 | 7 | echo "/metadata" 8 | python3 -c 'from lambda_tiler.handler import APP; resp = APP({"path": "/metadata", "queryStringParameters": {"url": "https://oin-hotosm.s3.amazonaws.com/5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif"}, "pathParameters": "null", "requestContext": "null", "httpMethod": "GET", "headers": {}}, None); print("OK") if resp["statusCode"] == 200 else print("NOK")' 9 | 10 | echo "/tilejson.json" 11 | python3 -c 'from lambda_tiler.handler import APP; resp = APP({"path": "/tilejson.json", "queryStringParameters": {"url": "https://oin-hotosm.s3.amazonaws.com/5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif"}, "pathParameters": "null", "requestContext": "null", "httpMethod": "GET", "headers": {}}, None); print("OK") if resp["statusCode"] == 200 else print("NOK")' 12 | 13 | echo "/tiles" 14 | python3 -c 'from lambda_tiler.handler import APP; resp = APP({"path": "/tiles/19/319379/270522.png", "queryStringParameters": {"url": "https://oin-hotosm.s3.amazonaws.com/5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif"}, "pathParameters": "null", "requestContext": "null", "httpMethod": "GET", "headers": {}}, None); print("OK") if resp["statusCode"] == 200 else print("NOK")' 15 | python3 -c 'from lambda_tiler.handler import APP; resp = APP({"path": "/tiles/19/319379/270522.png", "queryStringParameters": {"url": "https://oin-hotosm.s3.amazonaws.com/5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif", "expr":"(b3-b2)/(b3+b2)", "rescale": "-1,1"}, "pathParameters": "null", "requestContext": "null", "httpMethod": "GET", "headers": {}}, None); print("OK") if resp["statusCode"] == 200 else print("NOK")' 16 | 17 | echo "Done" -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | image: 5 | build: . 6 | image: lambda-tiler:latest 7 | 8 | package: 9 | image: lambda-tiler:latest 10 | volumes: 11 | - '.:/local' 12 | command: /local/bin/package.sh 13 | 14 | tests: 15 | image: lambci/lambda:build-python3.6 16 | environment: 17 | - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} 18 | - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} 19 | - GDAL_DATA=/var/runtime/share/gdal 20 | - GDAL_CACHEMAX=512 21 | - VSI_CACHE=TRUE 22 | - VSI_CACHE_SIZE=536870912 23 | - CPL_TMPDIR=/tmp 24 | - GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES 25 | - GDAL_HTTP_MULTIPLEX=YES 26 | - GDAL_HTTP_VERSION=2 27 | - PYTHONWARNINGS=ignore 28 | - GDAL_DISABLE_READDIR_ON_OPEN=FALSE 29 | - CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.TIF,.ovr 30 | volumes: 31 | - '.:/local' 32 | command: > 33 | bash -c "unzip -q /local/package.zip -d /var/runtime/ 34 | && sh /local/bin/tests.sh" -------------------------------------------------------------------------------- /lambda_tiler/__init__.py: -------------------------------------------------------------------------------- 1 | """Lambda-tiler.""" 2 | 3 | import pkg_resources 4 | 5 | version = pkg_resources.get_distribution(__package__).version 6 | -------------------------------------------------------------------------------- /lambda_tiler/handler.py: -------------------------------------------------------------------------------- 1 | """app.main: handle request for lambda-tiler.""" 2 | 3 | from typing import Any, BinaryIO, Dict, Tuple, Union 4 | 5 | import os 6 | import re 7 | import json 8 | import urllib 9 | from io import BytesIO 10 | 11 | import numpy 12 | 13 | import mercantile 14 | import rasterio 15 | from rasterio import warp 16 | from rasterio.transform import from_bounds 17 | 18 | from rio_tiler import main as cogTiler 19 | from rio_tiler.mercator import get_zooms 20 | from rio_tiler.profiles import img_profiles 21 | from rio_tiler.utils import ( 22 | array_to_image, 23 | get_colormap, 24 | expression, 25 | linear_rescale, 26 | _chunks, 27 | ) 28 | 29 | from rio_color.operations import parse_operations 30 | from rio_color.utils import scale_dtype, to_math_type 31 | 32 | from lambda_proxy.proxy import API 33 | 34 | from lambda_tiler.ogc import wmts_template 35 | from lambda_tiler.viewer import viewer_template 36 | 37 | 38 | APP = API(name="lambda-tiler") 39 | 40 | 41 | def _postprocess( 42 | tile: numpy.ndarray, 43 | mask: numpy.ndarray, 44 | rescale: str = None, 45 | color_formula: str = None, 46 | ) -> Tuple[numpy.ndarray, numpy.ndarray]: 47 | """Post-process tile data.""" 48 | if rescale: 49 | rescale_arr = list(map(float, rescale.split(","))) 50 | rescale_arr = list(_chunks(rescale_arr, 2)) 51 | if len(rescale_arr) != tile.shape[0]: 52 | rescale_arr = ((rescale_arr[0]),) * tile.shape[0] 53 | 54 | for bdx in range(tile.shape[0]): 55 | tile[bdx] = numpy.where( 56 | mask, 57 | linear_rescale( 58 | tile[bdx], in_range=rescale_arr[bdx], out_range=[0, 255] 59 | ), 60 | 0, 61 | ) 62 | tile = tile.astype(numpy.uint8) 63 | 64 | if color_formula: 65 | # make sure one last time we don't have 66 | # negative value before applying color formula 67 | tile[tile < 0] = 0 68 | for ops in parse_operations(color_formula): 69 | tile = scale_dtype(ops(to_math_type(tile)), numpy.uint8) 70 | 71 | return tile, mask 72 | 73 | 74 | class TilerError(Exception): 75 | """Base exception class.""" 76 | 77 | 78 | @APP.route( 79 | "/viewer", 80 | methods=["GET"], 81 | cors=True, 82 | payload_compression_method="gzip", 83 | binary_b64encode=True, 84 | tag=["viewer"], 85 | ) 86 | def viewer_handler(url: str, **kwargs: Dict) -> Tuple[str, str, str]: 87 | """Handle Viewer requests.""" 88 | qs = urllib.parse.urlencode(list(kwargs.items())) 89 | if qs: 90 | qs = "&".join(qs) 91 | else: 92 | qs = "" 93 | 94 | html = viewer_template.format(endpoint=APP.host, cogurl=url, tile_options=qs) 95 | return ("OK", "text/html", html) 96 | 97 | 98 | @APP.route( 99 | "/example", 100 | methods=["GET"], 101 | cors=True, 102 | payload_compression_method="gzip", 103 | binary_b64encode=True, 104 | tag=["viewer"], 105 | ) 106 | def example_handler() -> Tuple[str, str, str]: 107 | """Handle Example requests.""" 108 | url = ( 109 | "https://oin-hotosm.s3.amazonaws.com/" 110 | "5ac626e091b5310010e0d482/0/5ac626e091b5310010e0d483.tif" 111 | ) 112 | html = viewer_template.format(endpoint=APP.host, cogurl=url, tile_options="") 113 | return ("OK", "text/html", html) 114 | 115 | 116 | @APP.route( 117 | "/tilejson.json", 118 | methods=["GET"], 119 | cors=True, 120 | payload_compression_method="gzip", 121 | binary_b64encode=True, 122 | tag=["tiles"], 123 | ) 124 | def tilejson_handler(url: str, tile_format: str = "png", **kwargs: Dict): 125 | """Handle /tilejson.json requests.""" 126 | qs = urllib.parse.urlencode(list(kwargs.items())) 127 | tile_url = f"{APP.host}/tiles/{{z}}/{{x}}/{{y}}.{tile_format}?url={url}" 128 | if qs: 129 | tile_url += f"&{qs}" 130 | 131 | with rasterio.open(url) as src_dst: 132 | bounds = warp.transform_bounds( 133 | src_dst.crs, "epsg:4326", *src_dst.bounds, densify_pts=21 134 | ) 135 | center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2] 136 | minzoom, maxzoom = get_zooms(src_dst) 137 | 138 | meta = dict( 139 | bounds=bounds, 140 | center=center, 141 | minzoom=minzoom, 142 | maxzoom=maxzoom, 143 | name=os.path.basename(url), 144 | tilejson="2.1.0", 145 | tiles=[tile_url], 146 | ) 147 | return ("OK", "application/json", json.dumps(meta)) 148 | 149 | 150 | @APP.route( 151 | "/bounds", 152 | methods=["GET"], 153 | cors=True, 154 | payload_compression_method="gzip", 155 | binary_b64encode=True, 156 | tag=["metadata"], 157 | ) 158 | def bounds_handler(url: str) -> Tuple[str, str, str]: 159 | """Handle /bounds requests.""" 160 | info = cogTiler.bounds(url) 161 | return ("OK", "application/json", json.dumps(info)) 162 | 163 | 164 | @APP.route( 165 | "/metadata", 166 | methods=["GET"], 167 | cors=True, 168 | payload_compression_method="gzip", 169 | binary_b64encode=True, 170 | tag=["metadata"], 171 | ) 172 | def metadata_handler( 173 | url: str, 174 | pmin: Union[str, float] = 2.0, 175 | pmax: Union[str, float] = 98.0, 176 | nodata: Union[str, float, int, None] = None, 177 | indexes: Union[str, Tuple, int, None] = None, 178 | overview_level: Union[str, int, None] = None, 179 | max_size: Union[str, int] = 1024, 180 | histogram_bins: Union[str, int] = 20, 181 | histogram_range: Union[str, int, None] = None, 182 | ) -> Tuple[str, str, str]: 183 | """Handle /metadata requests.""" 184 | pmin = float(pmin) if isinstance(pmin, str) else pmin 185 | pmax = float(pmax) if isinstance(pmax, str) else pmax 186 | 187 | if nodata is not None and isinstance(nodata, str): 188 | nodata = numpy.nan if nodata == "nan" else float(nodata) 189 | 190 | if indexes is not None and isinstance(indexes, str): 191 | indexes = tuple(int(s) for s in re.findall(r"\d+", indexes)) 192 | 193 | if overview_level is not None and isinstance(overview_level, str): 194 | overview_level = int(overview_level) 195 | 196 | max_size = int(max_size) if isinstance(max_size, str) else max_size 197 | histogram_bins = ( 198 | int(histogram_bins) if isinstance(histogram_bins, str) else histogram_bins 199 | ) 200 | 201 | if histogram_range is not None and isinstance(histogram_range, str): 202 | histogram_range = tuple(map(float, histogram_range.split(","))) 203 | 204 | info = cogTiler.metadata( 205 | url, 206 | pmin=pmin, 207 | pmax=pmax, 208 | nodata=nodata, 209 | indexes=indexes, 210 | overview_level=overview_level, 211 | histogram_bins=histogram_bins, 212 | histogram_range=histogram_range, 213 | ) 214 | return ("OK", "application/json", json.dumps(info)) 215 | 216 | 217 | @APP.route( 218 | "/wmts", 219 | methods=["GET"], 220 | cors=True, 221 | payload_compression_method="gzip", 222 | binary_b64encode=True, 223 | tag=["OGC"], 224 | ) 225 | def _wmts( 226 | mosaicid: str = None, 227 | url: str = None, 228 | tile_format: str = "png", 229 | tile_scale: int = 1, 230 | title: str = "Cloud Optimizied GeoTIFF Mosaic", 231 | **kwargs: Any, 232 | ) -> Tuple[str, str, str]: 233 | """Handle /wmts requests.""" 234 | if tile_scale is not None and isinstance(tile_scale, str): 235 | tile_scale = int(tile_scale) 236 | 237 | kwargs.pop("SERVICE", None) 238 | kwargs.pop("REQUEST", None) 239 | kwargs.update(dict(url=url)) 240 | query_string = urllib.parse.urlencode(list(kwargs.items())) 241 | query_string = query_string.replace( 242 | "&", "&" 243 | ) # & is an invalid character in XML 244 | 245 | with rasterio.open(url) as src_dst: 246 | bounds = warp.transform_bounds( 247 | src_dst.crs, "epsg:4326", *src_dst.bounds, densify_pts=21 248 | ) 249 | minzoom, maxzoom = get_zooms(src_dst) 250 | 251 | return ( 252 | "OK", 253 | "application/xml", 254 | wmts_template( 255 | f"{APP.host}", 256 | os.path.basename(url), 257 | query_string, 258 | minzoom=minzoom, 259 | maxzoom=maxzoom, 260 | bounds=bounds, 261 | tile_scale=tile_scale, 262 | tile_format=tile_format, 263 | title=title, 264 | ), 265 | ) 266 | 267 | 268 | @APP.route( 269 | "/tiles///.", 270 | methods=["GET"], 271 | cors=True, 272 | payload_compression_method="gzip", 273 | binary_b64encode=True, 274 | tag=["tiles"], 275 | ) 276 | @APP.route( 277 | "/tiles///", 278 | methods=["GET"], 279 | cors=True, 280 | payload_compression_method="gzip", 281 | binary_b64encode=True, 282 | tag=["tiles"], 283 | ) 284 | @APP.route( 285 | "/tiles///@x.", 286 | methods=["GET"], 287 | cors=True, 288 | payload_compression_method="gzip", 289 | binary_b64encode=True, 290 | tag=["tiles"], 291 | ) 292 | @APP.route( 293 | "/tiles///@x", 294 | methods=["GET"], 295 | cors=True, 296 | payload_compression_method="gzip", 297 | binary_b64encode=True, 298 | tag=["tiles"], 299 | ) 300 | def tile_handler( 301 | z: int, 302 | x: int, 303 | y: int, 304 | scale: int = 1, 305 | ext: str = None, 306 | url: str = None, 307 | indexes: Union[str, Tuple[int]] = None, 308 | expr: str = None, 309 | nodata: Union[str, int, float] = None, 310 | rescale: str = None, 311 | color_formula: str = None, 312 | color_map: str = None, 313 | ) -> Tuple[str, str, BinaryIO]: 314 | """Handle /tiles requests.""" 315 | if indexes and expr: 316 | raise TilerError("Cannot pass indexes and expression") 317 | 318 | if not url: 319 | raise TilerError("Missing 'url' parameter") 320 | 321 | if isinstance(indexes, str): 322 | indexes = tuple(int(s) for s in re.findall(r"\d+", indexes)) 323 | 324 | if nodata is not None: 325 | nodata = numpy.nan if nodata == "nan" else float(nodata) 326 | 327 | tilesize = scale * 256 328 | if expr is not None: 329 | tile, mask = expression( 330 | url, x, y, z, expr=expr, tilesize=tilesize, nodata=nodata 331 | ) 332 | else: 333 | tile, mask = cogTiler.tile( 334 | url, x, y, z, indexes=indexes, tilesize=tilesize, nodata=nodata 335 | ) 336 | 337 | if not ext: 338 | ext = "jpg" if mask.all() else "png" 339 | 340 | rtile, rmask = _postprocess( 341 | tile, mask, rescale=rescale, color_formula=color_formula 342 | ) 343 | 344 | if color_map: 345 | color_map = get_colormap(color_map, format="gdal") 346 | 347 | driver = "jpeg" if ext == "jpg" else ext 348 | options = img_profiles.get(driver, {}) 349 | if ext == "tif": 350 | ext = "tiff" 351 | driver = "GTiff" 352 | mercator_tile = mercantile.Tile(x=x, y=y, z=z) 353 | bounds = mercantile.xy_bounds(mercator_tile) 354 | w, s, e, n = bounds 355 | dst_transform = from_bounds(w, s, e, n, rtile.shape[1], rtile.shape[2]) 356 | options = dict( 357 | dtype=rtile.dtype, crs={"init": "EPSG:3857"}, transform=dst_transform 358 | ) 359 | 360 | if ext == "npy": 361 | sio = BytesIO() 362 | numpy.save(sio, (rtile, mask)) 363 | sio.seek(0) 364 | return ("OK", "application/x-binary", sio.getvalue()) 365 | else: 366 | return ( 367 | "OK", 368 | f"image/{ext}", 369 | array_to_image( 370 | rtile, rmask, img_format=driver, color_map=color_map, **options 371 | ), 372 | ) 373 | 374 | 375 | @APP.route("/favicon.ico", methods=["GET"], cors=True, tag=["other"]) 376 | def favicon() -> Tuple[str, str, str]: 377 | """Favicon.""" 378 | return ("EMPTY", "text/plain", "") 379 | -------------------------------------------------------------------------------- /lambda_tiler/ogc.py: -------------------------------------------------------------------------------- 1 | """OCG wmts template.""" 2 | 3 | from typing import Tuple 4 | 5 | 6 | def wmts_template( 7 | endpoint: str, 8 | layer_name: str, 9 | query_string: str = "", 10 | minzoom: int = 0, 11 | maxzoom: int = 25, 12 | bounds: Tuple = [-180, -85.051129, 85.051129, 180], 13 | tile_scale: int = 1, 14 | tile_format: str = "png", 15 | title: str = "Cloud Optimizied GeoTIFF", 16 | ) -> str: 17 | """ 18 | Create WMTS XML template. 19 | 20 | Attributes 21 | ---------- 22 | endpoint : str, required 23 | lambda tiler endpoint. 24 | layer_name : str, required 25 | Layer name. 26 | query_string : str, optional 27 | Endpoint querystring. 28 | minzoom : int, optional (default: 0) 29 | Mosaic min zoom. 30 | maxzoom : int, optional (default: 25) 31 | Mosaic max zoom. 32 | bounds : tuple, optional (default: [-180, -85.051129, 85.051129, 180]) 33 | WGS84 layer bounds. 34 | tile_scale : int, optional (default: 1 -> 256px) 35 | Tile endpoint size scale. 36 | tile_format: str, optional (default: png) 37 | Tile image type. 38 | title: str, optional (default: "Cloud Optimizied GeoTIFF") 39 | Layer title. 40 | 41 | Returns 42 | ------- 43 | xml : str 44 | OGC Web Map Tile Service (WMTS) XML template. 45 | 46 | """ 47 | media_type = "tiff" if tile_format == "tif" else tile_format 48 | content_type = f"image/{media_type}" 49 | tilesize = 256 * tile_scale 50 | 51 | tileMatrix = [] 52 | for zoom in range(minzoom, maxzoom + 1): 53 | tm = f""" 54 | {zoom} 55 | {559082264.02872 / 2 ** zoom / tile_scale} 56 | -20037508.34278925 20037508.34278925 57 | {tilesize} 58 | {tilesize} 59 | {2 ** zoom} 60 | {2 ** zoom} 61 | """ 62 | tileMatrix.append(tm) 63 | tileMatrix = "\n".join(tileMatrix) 64 | 65 | xml = f""" 73 | 74 | {title} 75 | OGC WMTS 76 | 1.0.0 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | RESTful 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | RESTful 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | {title} 109 | {layer_name} 110 | cogeo-mosaic 111 | 112 | {bounds[0]} {bounds[1]} 113 | {bounds[2]} {bounds[3]} 114 | 115 | 118 | {content_type} 119 | 120 | GoogleMapsCompatible 121 | 122 | 126 | 127 | 128 | GoogleMapsCompatible 129 | GoogleMapsCompatible EPSG:3857 130 | GoogleMapsCompatible 131 | urn:ogc:def:crs:EPSG::3857 132 | {tileMatrix} 133 | 134 | 135 | 136 | """ 137 | 138 | return xml 139 | -------------------------------------------------------------------------------- /lambda_tiler/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """lambda_pyskel scripts.""" 2 | -------------------------------------------------------------------------------- /lambda_tiler/scripts/cli.py: -------------------------------------------------------------------------------- 1 | """Test lambda-tiler locally.""" 2 | 3 | import click 4 | import base64 5 | 6 | from socketserver import ThreadingMixIn 7 | 8 | from urllib.parse import urlparse, parse_qsl 9 | from http.server import HTTPServer, BaseHTTPRequestHandler 10 | 11 | from lambda_tiler.handler import APP 12 | 13 | # Local server is unsecure 14 | APP.https = False 15 | 16 | 17 | class ThreadingSimpleServer(ThreadingMixIn, HTTPServer): 18 | """MultiThread.""" 19 | 20 | pass 21 | 22 | 23 | class Handler(BaseHTTPRequestHandler): 24 | """Requests handler.""" 25 | 26 | def do_GET(self): 27 | """Get requests.""" 28 | q = urlparse(self.path) 29 | request = { 30 | "headers": dict(self.headers), 31 | "path": q.path, 32 | "queryStringParameters": dict(parse_qsl(q.query)), 33 | "httpMethod": self.command, 34 | } 35 | response = APP(request, None) 36 | 37 | self.send_response(int(response["statusCode"])) 38 | for r in response["headers"]: 39 | self.send_header(r, response["headers"][r]) 40 | self.end_headers() 41 | 42 | if response.get("isBase64Encoded"): 43 | response["body"] = base64.b64decode(response["body"]) 44 | 45 | if isinstance(response["body"], str): 46 | self.wfile.write(bytes(response["body"], "utf-8")) 47 | else: 48 | self.wfile.write(response["body"]) 49 | 50 | def do_POST(self): 51 | """POST requests.""" 52 | q = urlparse(self.path) 53 | body = self.rfile.read(int(dict(self.headers).get("Content-Length"))) 54 | body = base64.b64encode(body).decode() 55 | request = { 56 | "headers": dict(self.headers), 57 | "path": q.path, 58 | "queryStringParameters": dict(parse_qsl(q.query)), 59 | "body": body, 60 | "httpMethod": self.command, 61 | } 62 | 63 | response = APP(request, None) 64 | 65 | self.send_response(int(response["statusCode"])) 66 | for r in response["headers"]: 67 | self.send_header(r, response["headers"][r]) 68 | self.end_headers() 69 | if isinstance(response["body"], str): 70 | self.wfile.write(bytes(response["body"], "utf-8")) 71 | else: 72 | self.wfile.write(response["body"]) 73 | 74 | 75 | @click.command(short_help="Local Server") 76 | @click.option("--port", type=int, default=8000, help="port") 77 | def run(port): 78 | """Launch server.""" 79 | server_address = ("127.0.0.1", port) 80 | httpd = ThreadingSimpleServer(server_address, Handler) 81 | click.echo(f"Starting local server at http://127.0.0.1:{port}", err=True) 82 | httpd.serve_forever() 83 | 84 | 85 | if __name__ == "__main__": 86 | run() 87 | -------------------------------------------------------------------------------- /lambda_tiler/viewer.py: -------------------------------------------------------------------------------- 1 | """Lambda-tiler: Viewer template.""" 2 | 3 | viewer_template = """ 4 | 5 | 6 | 7 | 8 | example 9 | 13 | 15 | 17 | 20 | 24 | 28 | 29 | 30 | 31 |
32 | 105 | 106 | 107 | 108 | """ 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-tiler", 3 | "version": "1.1.0", 4 | "description": "Create a highly customizable `serverless` tile server", 5 | "devDependencies": { 6 | "serverless": "^1.23.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/vincentsarago/lambda-tiler.git" 11 | }, 12 | "author": "Vincent Sarago" 13 | } 14 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: lambda-tiler 2 | 3 | provider: 4 | name: aws 5 | runtime: python3.7 6 | stage: ${opt:stage, 'production'} 7 | region: ${opt:region, 'us-east-1'} 8 | 9 | # Add optional bucket deployement 10 | # deploymentBucket: ${opt:bucket} 11 | 12 | environment: 13 | CPL_TMPDIR: /tmp 14 | GDAL_CACHEMAX: 512 15 | GDAL_DATA: /opt/share/gdal 16 | GDAL_DISABLE_READDIR_ON_OPEN: EMPTY_DIR 17 | GDAL_HTTP_MERGE_CONSECUTIVE_RANGES: YES 18 | GDAL_HTTP_MULTIPLEX: YES 19 | GDAL_HTTP_VERSION: 2 20 | MAX_THREADS: 50 21 | PROJ_LIB: /opt/share/proj 22 | PYTHONWARNINGS: ignore 23 | VSI_CACHE: TRUE 24 | VSI_CACHE_SIZE: 536870912 25 | 26 | # Add IAM Role statements to allow the tiler to access files 27 | # iamRoleStatements: 28 | # - Effect: "Allow" 29 | # Action: 30 | # - "s3:ListBucket" 31 | # - "s3:GetObject" 32 | # Resource: 33 | # - "arn:aws:s3:::landsat-pds*" 34 | 35 | apiGateway: 36 | binaryMediaTypes: 37 | - '*/*' 38 | 39 | package: 40 | artifact: package.zip 41 | 42 | functions: 43 | lambda-tiler: 44 | handler: lambda_tiler.handler.APP 45 | memorySize: 1536 46 | timeout: 20 47 | layers: 48 | - arn:aws:lambda:${self:provider.region}:524387336408:layer:gdal30-py37-cogeo:8 49 | events: 50 | - http: 51 | path: /{proxy+} 52 | method: get 53 | cors: true 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup lambda-tiler.""" 2 | 3 | from setuptools import setup, find_packages 4 | 5 | # Runtime requirements. 6 | inst_reqs = ["lambda-proxy~=5.0", "rio-tiler", "rio-color"] 7 | 8 | extra_reqs = { 9 | "test": ["mock", "pytest", "pytest-cov"], 10 | "dev": ["mock", "pytest", "pytest-cov", "pre-commit"], 11 | } 12 | 13 | setup( 14 | name="lambda-tiler", 15 | version="3.0.0", 16 | description=u"""""", 17 | long_description=u"", 18 | python_requires=">=3", 19 | classifiers=["Programming Language :: Python :: 3.6"], 20 | keywords="", 21 | author=u"Vincent Sarago", 22 | author_email="contact@remotepixel.ca", 23 | url="https://github.com/vincentsarago/lambda_tiler", 24 | license="BSD", 25 | packages=find_packages(exclude=["ez_setup", "examples", "tests"]), 26 | include_package_data=True, 27 | zip_safe=False, 28 | install_requires=inst_reqs, 29 | extras_require=extra_reqs, 30 | entry_points={"console_scripts": ["lambda-tiler = lambda_tiler.scripts.cli:run"]}, 31 | ) 32 | -------------------------------------------------------------------------------- /tests/fixtures/cog.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vincentsarago/lambda-tiler/adbf119ee0289bfe6ad7fa9e88a2968aa22a65f2/tests/fixtures/cog.tif -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Test: lambda-tiler API.""" 2 | 3 | import os 4 | import json 5 | 6 | import pytest 7 | from mock import patch 8 | 9 | import numpy 10 | 11 | from lambda_tiler.handler import APP 12 | 13 | cog_path = os.path.join(os.path.dirname(__file__), "fixtures", "cog.tif") 14 | 15 | 16 | @pytest.fixture() 17 | def event(): 18 | """Event fixture.""" 19 | return { 20 | "path": "/", 21 | "httpMethod": "GET", 22 | "headers": {"Host": "test.apigw.com"}, 23 | "queryStringParameters": {}, 24 | } 25 | 26 | 27 | def test_API_favicon(event): 28 | """Test /favicon.ico route.""" 29 | event["path"] = "/favicon.ico" 30 | event["httpMethod"] = "GET" 31 | 32 | resp = { 33 | "body": "", 34 | "headers": { 35 | "Access-Control-Allow-Credentials": "true", 36 | "Access-Control-Allow-Methods": "GET", 37 | "Access-Control-Allow-Origin": "*", 38 | "Content-Type": "text/plain", 39 | }, 40 | "statusCode": 204, 41 | } 42 | res = APP(event, {}) 43 | assert res == resp 44 | 45 | 46 | def test_API_viewer(event): 47 | """Test /viewer route.""" 48 | event["path"] = f"/viewer" 49 | event["httpMethod"] = "GET" 50 | res = APP(event, {}) 51 | assert res["statusCode"] == 500 52 | headers = res["headers"] 53 | assert headers["Content-Type"] == "application/json" 54 | body = json.loads(res["body"]) 55 | assert "url" in body["errorMessage"] 56 | 57 | event["path"] = f"/viewer" 58 | event["httpMethod"] = "GET" 59 | event["queryStringParameters"] = {"url": cog_path} 60 | res = APP(event, {}) 61 | assert res["statusCode"] == 200 62 | headers = res["headers"] 63 | assert headers["Content-Type"] == "text/html" 64 | 65 | event["path"] = f"/example" 66 | event["httpMethod"] = "GET" 67 | event["queryStringParameters"] = {} 68 | res = APP(event, {}) 69 | assert res["statusCode"] == 200 70 | headers = res["headers"] 71 | assert headers["Content-Type"] == "text/html" 72 | 73 | 74 | def test_API_tilejson(event): 75 | """Test /tilejson.json route.""" 76 | event["path"] = f"/tilejson.json" 77 | event["httpMethod"] = "GET" 78 | res = APP(event, {}) 79 | assert res["statusCode"] == 500 80 | headers = res["headers"] 81 | assert headers["Content-Type"] == "application/json" 82 | body = json.loads(res["body"]) 83 | assert "url" in body["errorMessage"] 84 | 85 | event["path"] = f"/tilejson.json" 86 | event["httpMethod"] = "GET" 87 | event["queryStringParameters"] = {"url": cog_path} 88 | res = APP(event, {}) 89 | assert res["statusCode"] == 200 90 | headers = res["headers"] 91 | assert headers["Content-Type"] == "application/json" 92 | body = json.loads(res["body"]) 93 | assert body["name"] == "cog.tif" 94 | assert body["tilejson"] == "2.1.0" 95 | assert body["tiles"][0] == ( 96 | f"https://test.apigw.com/tiles/{{z}}/{{x}}/{{y}}.png?url={cog_path}" 97 | ) 98 | assert len(body["bounds"]) == 4 99 | assert len(body["center"]) == 2 100 | assert body["minzoom"] == 6 101 | assert body["maxzoom"] == 8 102 | 103 | # test with tile_format 104 | event["path"] = f"/tilejson.json" 105 | event["httpMethod"] = "GET" 106 | event["queryStringParameters"] = {"url": cog_path, "tile_format": "jpg"} 107 | res = APP(event, {}) 108 | assert res["statusCode"] == 200 109 | headers = res["headers"] 110 | assert headers["Content-Type"] == "application/json" 111 | body = json.loads(res["body"]) 112 | assert body["tiles"][0] == ( 113 | f"https://test.apigw.com/tiles/{{z}}/{{x}}/{{y}}.jpg?url={cog_path}" 114 | ) 115 | 116 | # test with kwargs 117 | event["path"] = f"/tilejson.json" 118 | event["httpMethod"] = "GET" 119 | event["queryStringParameters"] = {"url": cog_path, "rescale": "-1,1"} 120 | res = APP(event, {}) 121 | assert res["statusCode"] == 200 122 | headers = res["headers"] 123 | assert headers["Content-Type"] == "application/json" 124 | body = json.loads(res["body"]) 125 | assert body["tiles"][0] == ( 126 | f"https://test.apigw.com/tiles/{{z}}/{{x}}/{{y}}.png?url={cog_path}&rescale=-1%2C1" 127 | ) 128 | 129 | 130 | def test_API_bounds(event): 131 | """Test /bounds route.""" 132 | event["path"] = f"/bounds" 133 | event["httpMethod"] = "GET" 134 | res = APP(event, {}) 135 | assert res["statusCode"] == 500 136 | headers = res["headers"] 137 | assert headers["Content-Type"] == "application/json" 138 | body = json.loads(res["body"]) 139 | assert "url" in body["errorMessage"] 140 | 141 | event["path"] = f"/bounds" 142 | event["httpMethod"] = "GET" 143 | event["queryStringParameters"] = {"url": cog_path} 144 | res = APP(event, {}) 145 | assert res["statusCode"] == 200 146 | headers = res["headers"] 147 | assert headers["Content-Type"] == "application/json" 148 | body = json.loads(res["body"]) 149 | assert body["url"] 150 | assert len(body["bounds"]) == 4 151 | 152 | 153 | def test_API_metadata(event): 154 | """Test /metadata route.""" 155 | event["path"] = f"/metadata" 156 | event["httpMethod"] = "GET" 157 | res = APP(event, {}) 158 | assert res["statusCode"] == 500 159 | headers = res["headers"] 160 | assert headers["Content-Type"] == "application/json" 161 | body = json.loads(res["body"]) 162 | assert "url" in body["errorMessage"] 163 | 164 | event["path"] = f"/metadata" 165 | event["httpMethod"] = "GET" 166 | event["queryStringParameters"] = {"url": cog_path} 167 | res = APP(event, {}) 168 | assert res["statusCode"] == 200 169 | headers = res["headers"] 170 | assert headers["Content-Type"] == "application/json" 171 | body = json.loads(res["body"]) 172 | assert body["address"] 173 | assert len(body["bounds"]["value"]) == 4 174 | assert body["bounds"]["crs"] == "EPSG:4326" 175 | assert len(body["statistics"].keys()) == 1 176 | assert len(body["statistics"]["1"]["histogram"][0]) == 20 177 | assert body["minzoom"] 178 | assert body["maxzoom"] 179 | assert body["band_descriptions"] 180 | 181 | event["path"] = f"/metadata" 182 | event["httpMethod"] = "GET" 183 | event["queryStringParameters"] = {"url": cog_path, "histogram_bins": "10"} 184 | res = APP(event, {}) 185 | assert res["statusCode"] == 200 186 | headers = res["headers"] 187 | assert headers["Content-Type"] == "application/json" 188 | body = json.loads(res["body"]) 189 | assert len(body["statistics"]["1"]["histogram"][0]) == 10 190 | 191 | event["queryStringParameters"] = { 192 | "url": cog_path, 193 | "pmin": "5", 194 | "pmax": "95", 195 | "nodata": "-9999", 196 | "indexes": "1", 197 | "overview_level": "1", 198 | "histogram_range": "1,1000", 199 | } 200 | res = APP(event, {}) 201 | assert res["statusCode"] == 200 202 | headers = res["headers"] 203 | assert headers["Content-Type"] == "application/json" 204 | body = json.loads(res["body"]) 205 | assert len(body["statistics"].keys()) == 1 206 | 207 | 208 | def test_API_tiles(event): 209 | """Test /tiles route.""" 210 | # test missing url in queryString 211 | event["path"] = f"/tiles/7/62/44.jpg" 212 | event["httpMethod"] = "GET" 213 | res = APP(event, {}) 214 | assert res["statusCode"] == 500 215 | headers = res["headers"] 216 | assert headers["Content-Type"] == "application/json" 217 | body = json.loads(res["body"]) 218 | assert body["errorMessage"] == "Missing 'url' parameter" 219 | 220 | # test missing expr and indexes in queryString 221 | event["path"] = f"/tiles/7/62/44.jpg" 222 | event["httpMethod"] = "GET" 223 | event["queryStringParameters"] = {"url": cog_path, "indexes": "1", "expr": "b1/b1"} 224 | res = APP(event, {}) 225 | assert res["statusCode"] == 500 226 | headers = res["headers"] 227 | assert headers["Content-Type"] == "application/json" 228 | body = json.loads(res["body"]) 229 | assert body["errorMessage"] == "Cannot pass indexes and expression" 230 | 231 | # test valid request with linear rescaling 232 | event["path"] = f"/tiles/7/62/44.png" 233 | event["httpMethod"] = "GET" 234 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 235 | res = APP(event, {}) 236 | assert res["statusCode"] == 200 237 | headers = res["headers"] 238 | assert headers["Content-Type"] == "image/png" 239 | assert res["body"] 240 | assert res["isBase64Encoded"] 241 | 242 | # test valid request with expression 243 | event["path"] = f"/tiles/7/62/44.png" 244 | event["httpMethod"] = "GET" 245 | event["queryStringParameters"] = { 246 | "url": cog_path, 247 | "expr": "b1/b1", 248 | "rescale": "0,1", 249 | } 250 | res = APP(event, {}) 251 | assert res["statusCode"] == 200 252 | headers = res["headers"] 253 | assert headers["Content-Type"] == "image/png" 254 | assert res["body"] 255 | assert res["isBase64Encoded"] 256 | 257 | # test valid jpg request with linear rescaling 258 | event["path"] = f"/tiles/7/62/44.jpg" 259 | event["httpMethod"] = "GET" 260 | event["queryStringParameters"] = { 261 | "url": cog_path, 262 | "rescale": "0,10000", 263 | "indexes": "1", 264 | "nodata": "-9999", 265 | } 266 | res = APP(event, {}) 267 | assert res["statusCode"] == 200 268 | headers = res["headers"] 269 | assert headers["Content-Type"] == "image/jpg" 270 | assert res["body"] 271 | assert res["isBase64Encoded"] 272 | 273 | # test valid jpg request with rescaling and colormap 274 | event["path"] = f"/tiles/7/62/44.png" 275 | event["httpMethod"] = "GET" 276 | event["queryStringParameters"] = { 277 | "url": cog_path, 278 | "rescale": "0,10000", 279 | "color_map": "schwarzwald", 280 | } 281 | res = APP(event, {}) 282 | assert res["statusCode"] == 200 283 | headers = res["headers"] 284 | assert headers["Content-Type"] == "image/png" 285 | assert res["body"] 286 | assert res["isBase64Encoded"] 287 | 288 | # test scale (512px tile size) 289 | event["path"] = f"/tiles/7/62/44@2x.png" 290 | event["httpMethod"] = "GET" 291 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 292 | res = APP(event, {}) 293 | assert res["statusCode"] == 200 294 | headers = res["headers"] 295 | assert headers["Content-Type"] == "image/png" 296 | assert res["body"] 297 | assert res["isBase64Encoded"] 298 | 299 | # test no ext (partial: png) 300 | event["path"] = f"/tiles/7/62/44" 301 | event["httpMethod"] = "GET" 302 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 303 | res = APP(event, {}) 304 | assert res["statusCode"] == 200 305 | headers = res["headers"] 306 | assert headers["Content-Type"] == "image/png" 307 | assert res["body"] 308 | assert res["isBase64Encoded"] 309 | 310 | # test no ext (full: jpeg) 311 | event["path"] = f"/tiles/8/126/87" 312 | event["httpMethod"] = "GET" 313 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 314 | res = APP(event, {}) 315 | assert res["statusCode"] == 200 316 | headers = res["headers"] 317 | assert headers["Content-Type"] == "image/jpg" 318 | assert res["body"] 319 | assert res["isBase64Encoded"] 320 | 321 | 322 | @patch("lambda_tiler.handler.cogTiler.tile") 323 | def test_API_tilesMock(tiler, event): 324 | """Tests if route pass the right variables.""" 325 | tilesize = 256 326 | tile = numpy.random.rand(3, tilesize, tilesize).astype(numpy.int16) 327 | mask = numpy.full((tilesize, tilesize), 255) 328 | mask[0:100, 0:100] = 0 329 | 330 | tiler.return_value = (tile, mask) 331 | 332 | # test no ext 333 | event["path"] = f"/tiles/7/22/22" 334 | event["httpMethod"] = "GET" 335 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 336 | res = APP(event, {}) 337 | assert res["statusCode"] == 200 338 | assert res["body"] 339 | assert res["isBase64Encoded"] 340 | headers = res["headers"] 341 | assert headers["Content-Type"] == "image/png" 342 | kwargs = tiler.call_args[1] 343 | assert kwargs["tilesize"] == 256 344 | vars = tiler.call_args[0] 345 | assert vars[1] == 22 346 | assert vars[2] == 22 347 | assert vars[3] == 7 348 | 349 | # test no ext 350 | event["path"] = f"/tiles/21/62765/4787564" 351 | event["httpMethod"] = "GET" 352 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 353 | res = APP(event, {}) 354 | assert res["statusCode"] == 200 355 | assert res["body"] 356 | assert res["isBase64Encoded"] 357 | headers = res["headers"] 358 | assert headers["Content-Type"] == "image/png" 359 | kwargs = tiler.call_args[1] 360 | assert kwargs["tilesize"] == 256 361 | vars = tiler.call_args[0] 362 | assert vars[1] == 62765 363 | assert vars[2] == 4787564 364 | assert vars[3] == 21 365 | 366 | # test ext 367 | event["path"] = f"/tiles/21/62765/4787564.jpg" 368 | event["httpMethod"] = "GET" 369 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 370 | res = APP(event, {}) 371 | assert res["statusCode"] == 200 372 | assert res["body"] 373 | assert res["isBase64Encoded"] 374 | headers = res["headers"] 375 | assert headers["Content-Type"] == "image/jpg" 376 | kwargs = tiler.call_args[1] 377 | assert kwargs["tilesize"] == 256 378 | vars = tiler.call_args[0] 379 | assert vars[1] == 62765 380 | assert vars[2] == 4787564 381 | assert vars[3] == 21 382 | 383 | tilesize = 512 384 | tile = numpy.random.rand(3, tilesize, tilesize).astype(numpy.int16) 385 | mask = numpy.full((tilesize, tilesize), 255) 386 | tiler.return_value = (tile, mask) 387 | mask[0:100, 0:100] = 0 388 | 389 | # test scale 390 | event["path"] = f"/tiles/21/62765/4787564@2x" 391 | event["httpMethod"] = "GET" 392 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 393 | res = APP(event, {}) 394 | assert res["statusCode"] == 200 395 | assert res["body"] 396 | assert res["isBase64Encoded"] 397 | headers = res["headers"] 398 | assert headers["Content-Type"] == "image/png" 399 | kwargs = tiler.call_args[1] 400 | assert kwargs["tilesize"] == 512 401 | vars = tiler.call_args[0] 402 | assert vars[1] == 62765 403 | assert vars[2] == 4787564 404 | assert vars[3] == 21 405 | 406 | # test scale 407 | event["path"] = f"/tiles/21/62765/4787564@2x.png" 408 | event["httpMethod"] = "GET" 409 | event["queryStringParameters"] = {"url": cog_path, "rescale": "0,10000"} 410 | res = APP(event, {}) 411 | assert res["statusCode"] == 200 412 | assert res["body"] 413 | assert res["isBase64Encoded"] 414 | headers = res["headers"] 415 | assert headers["Content-Type"] == "image/png" 416 | kwargs = tiler.call_args[1] 417 | assert kwargs["tilesize"] == 512 418 | vars = tiler.call_args[0] 419 | assert vars[1] == 62765 420 | assert vars[2] == 4787564 421 | assert vars[3] == 21 422 | 423 | # test tif 424 | event["path"] = f"/tiles/21/62765/4787564.tif" 425 | event["httpMethod"] = "GET" 426 | event["queryStringParameters"] = {"url": cog_path} 427 | res = APP(event, {}) 428 | assert res["statusCode"] == 200 429 | assert res["body"] 430 | assert res["isBase64Encoded"] 431 | headers = res["headers"] 432 | assert headers["Content-Type"] == "image/tiff" 433 | kwargs = tiler.call_args[1] 434 | assert kwargs["tilesize"] == 256 435 | vars = tiler.call_args[0] 436 | assert vars[1] == 62765 437 | assert vars[2] == 4787564 438 | assert vars[3] == 21 439 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,py37 3 | 4 | [testenv] 5 | extras = test 6 | commands= 7 | python -m pytest --cov lambda_tiler --cov-report term-missing --ignore=venv 8 | deps= 9 | numpy 10 | 11 | # Autoformatter 12 | [testenv:black] 13 | basepython = python3 14 | skip_install = true 15 | deps = 16 | black 17 | commands = 18 | black 19 | 20 | # Lint 21 | [flake8] 22 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist 23 | max-line-length = 90 24 | --------------------------------------------------------------------------------