├── lambda_tiler
├── scripts
│ ├── __init__.py
│ └── cli.py
├── __init__.py
├── viewer.py
├── ogc.py
└── handler.py
├── tests
├── fixtures
│ └── cog.tif
└── test_api.py
├── codecov.yml
├── Dockerfile
├── Makefile
├── package.json
├── tox.ini
├── bin
├── package.sh
└── tests.sh
├── docker-compose.yml
├── setup.py
├── .circleci
└── config.yml
├── .pre-commit-config.yaml
├── serverless.yml
├── LICENSE
├── .gitignore
└── README.md
/lambda_tiler/scripts/__init__.py:
--------------------------------------------------------------------------------
1 | """lambda_pyskel scripts."""
2 |
--------------------------------------------------------------------------------
/tests/fixtures/cog.tif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vincentsarago/lambda-tiler/HEAD/tests/fixtures/cog.tif
--------------------------------------------------------------------------------
/lambda_tiler/__init__.py:
--------------------------------------------------------------------------------
1 | """Lambda-tiler."""
2 |
3 | import pkg_resources
4 |
5 | version = pkg_resources.get_distribution(__package__).version
6 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | comment: off
2 |
3 | coverage:
4 | status:
5 | project:
6 | default:
7 | target: auto
8 | threshold: 5
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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"
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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"
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # lambda-tiler
2 |
3 | [](https://circleci.com/gh/vincentsarago/lambda-tiler)
4 | [](https://codecov.io/gh/vincentsarago/lambda-tiler)
5 |
6 | #### AWS Lambda + rio-tiler to serve tiles from any web hosted files
7 |
8 | 
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`
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------