├── .github
├── codecov.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGES.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── pyproject.toml
├── tests
├── __init__.py
├── fixtures
│ └── WGS1984Quad.json
├── test_cli.py
├── test_middleware.py
├── test_reader.py
├── test_tilebench.py
└── test_viz.py
└── tilebench
├── __init__.py
├── middleware.py
├── resources
├── __init__.py
└── responses.py
├── scripts
├── __init__.py
└── cli.py
├── static
└── spherical-mercator.js
├── templates
└── index.html
└── viz.py
/.github/codecov.yml:
--------------------------------------------------------------------------------
1 | comment: off
2 |
3 | coverage:
4 | status:
5 | project:
6 | default:
7 | target: auto
8 | threshold: 5
9 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | # On every pull request, but only on push to main
4 | on:
5 | push:
6 | branches:
7 | - main
8 | tags:
9 | - '*'
10 | pull_request:
11 | env:
12 | LATEST_PY_VERSION: '3.13'
13 |
14 | jobs:
15 | tests:
16 | runs-on: ubuntu-latest
17 | strategy:
18 | matrix:
19 | python-version:
20 | - '3.9'
21 | - '3.10'
22 | - '3.11'
23 | - '3.12'
24 | - '3.13'
25 |
26 | steps:
27 | - uses: actions/checkout@v4
28 | - name: Set up Python ${{ matrix.python-version }}
29 | uses: actions/setup-python@v5
30 | with:
31 | python-version: ${{ matrix.python-version }}
32 |
33 | - name: Install dependencies
34 | run: |
35 | python -m pip install --upgrade pip
36 | python -m pip install .["test"]
37 |
38 | - name: Run tests
39 | run: python -m pytest --cov tilebench --cov-report xml --cov-report term-missing
40 |
41 | - name: run pre-commit
42 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }}
43 | run: |
44 | python -m pip install pre-commit
45 | pre-commit run --all-files
46 |
47 | - name: Upload Results
48 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }}
49 | uses: codecov/codecov-action@v1
50 | with:
51 | file: ./coverage.xml
52 | flags: unittests
53 | name: ${{ matrix.python-version }}
54 | fail_ci_if_error: false
55 |
56 | publish:
57 | needs: [tests]
58 | runs-on: ubuntu-latest
59 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release'
60 | steps:
61 | - uses: actions/checkout@v4
62 | - name: Set up Python ${{ env.LATEST_PY_VERSION }}
63 | uses: actions/setup-python@v5
64 | with:
65 | python-version: ${{ env.LATEST_PY_VERSION }}
66 |
67 | - name: Install dependencies
68 | run: |
69 | python -m pip install --upgrade pip
70 | python -m pip install hatch
71 | python -m hatch build
72 |
73 | - name: Set tag version
74 | id: tag
75 | run: |
76 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
77 |
78 | - name: Set module version
79 | id: module
80 | run: |
81 | echo "version=$(hatch --quiet version)" >> $GITHUB_OUTPUT
82 |
83 | - name: Show version
84 | run: |
85 | echo "${{ steps.tag.outputs.version }}"
86 | echo "${{ steps.module.outputs.version }}"
87 |
88 | - name: publish
89 | if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}}
90 | env:
91 | HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }}
92 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }}
93 | run: |
94 | python -m hatch publish
95 |
96 | publish-docker:
97 | needs: [tests]
98 | if: github.ref == 'refs/heads/main' || startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release'
99 | runs-on: ubuntu-latest
100 | steps:
101 | - name: Checkout
102 | uses: actions/checkout@v4
103 |
104 | - name: Set up QEMU
105 | uses: docker/setup-qemu-action@v1
106 |
107 | - name: Set up Docker Buildx
108 | uses: docker/setup-buildx-action@v1
109 |
110 | - name: Login to Github
111 | uses: docker/login-action@v1
112 | with:
113 | registry: ghcr.io
114 | username: ${{ github.actor }}
115 | password: ${{ secrets.GITHUB_TOKEN }}
116 |
117 | - name: Set tag version
118 | id: tag
119 | run: |
120 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
121 |
122 | # Push `latest` when commiting to main
123 | - name: Build and push
124 | if: github.ref == 'refs/heads/main'
125 | uses: docker/build-push-action@v2
126 | with:
127 | # See https://github.com/developmentseed/titiler/discussions/387
128 | platforms: linux/amd64
129 | context: .
130 | file: Dockerfile
131 | push: true
132 | tags: |
133 | ghcr.io/${{ github.repository }}:latest
134 |
135 | # Push `{VERSION}` when pushing a new tag
136 | - name: Build and push
137 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release'
138 | uses: docker/build-push-action@v2
139 | with:
140 | # See https://github.com/developmentseed/titiler/discussions/387
141 | platforms: linux/amd64
142 | context: .
143 | file: Dockerfile
144 | push: true
145 | tags: |
146 | ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.version }}
147 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/abravalheri/validate-pyproject
3 | rev: v0.12.1
4 | hooks:
5 | - id: validate-pyproject
6 |
7 | - repo: https://github.com/PyCQA/isort
8 | rev: 5.13.2
9 | hooks:
10 | - id: isort
11 | language_version: python
12 |
13 | - repo: https://github.com/astral-sh/ruff-pre-commit
14 | rev: v0.8.4
15 | hooks:
16 | - id: ruff
17 | args: ["--fix"]
18 | - id: ruff-format
19 |
20 | - repo: https://github.com/pre-commit/mirrors-mypy
21 | rev: v1.11.2
22 | hooks:
23 | - id: mypy
24 | language_version: python
25 | # No reason to run if only tests have changed. They intentionally break typing.
26 | exclude: tests/.*
27 | additional_dependencies:
28 | - types-attrs
29 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | ## 0.16.0 (2025-04-30)
2 |
3 | * new Range request parsing logic to make sure it works with S3 and HTTPS files
4 |
5 | ## 0.15.0 (2025-02-27)
6 |
7 | * add support for `VSIFile` backend (https://github.com/developmentseed/tilebench/pull/27)
8 |
9 | ## 0.14.0 (2025-01-06)
10 |
11 | * remove `python 3.8` support
12 | * add `python 3.13` support
13 |
14 | ## 0.13.0 (2024-10-23)
15 |
16 | * update rio-tiler dependency to `>=7.0,<8.0`
17 | * add `reader-params` options in CLI
18 |
19 | ## 0.12.1 (2024-04-18)
20 |
21 | * fix GET range parsing
22 | * add python 3.12 official support
23 |
24 | ## 0.12.0 (2024-01-24)
25 |
26 | * allow `tms` options in CLI (`profile`, `random` and `get-zooms`) to select TileMatrixSet
27 |
28 | ## 0.11.0 (2023-10-18)
29 |
30 | * update requirements
31 | - `rio-tiler>=6.0,<7.0`
32 | - `fastapi>=0.100.0`
33 | - `rasterio>=1.3.8`
34 |
35 | * remove `wurlitzer` dependency
36 |
37 | * only use `rasterio` logs
38 |
39 | * remove `LIST` information **breaking change**
40 |
41 | ## 0.10.0 (2023-06-02)
42 |
43 | * update `rio-tiler` requirement
44 | * fix log parsing when `CPL_TIMESTAMP=ON` is set
45 |
46 | ## 0.9.1 (2023-03-24)
47 |
48 | * handle dateline crossing dataset and remove pydantic serialization
49 |
50 | ## 0.9.0 (2023-03-14)
51 |
52 | * update pre-commit and fix issue with starlette>=0.26
53 | * re-write `NoCacheMiddleware` as pure ASGI middleware
54 | * rename `analyse_logs` to `parse_logs`
55 | * add python 3.11 support
56 |
57 | ## 0.8.2 (2022-11-21)
58 |
59 | * update hatch config
60 |
61 | ## 0.8.1 (2022-10-31)
62 |
63 | * fix issue with min/max zoom when there is no overviews
64 | * calculate windows from block_shapes
65 |
66 | ## 0.8.0 (2022-10-25)
67 |
68 | * update rio-tiler/rasterio dependencies
69 | * remove python 3.7 support
70 | * add python 3.10 support
71 | * add image endpoint to show the data footprint
72 | * switch from mapbox to maplibre
73 |
74 | ## 0.7.0 (2022-06-14)
75 |
76 | * add `cProfile` stats
77 |
78 | ## 0.6.1 (2022-04-19)
79 |
80 | * Remove usage of `VSIStatsMiddleware` in `tilebench viz`
81 |
82 | ## 0.6.0 (2022-04-19)
83 |
84 | * switch to pyproject.toml
85 |
86 | ## 0.5.1 (2022-03-04)
87 |
88 | * make sure we don't cache previous request when using `tilebench profile` without `--tile` option
89 |
90 | ## 0.5.0 (2022-02-28)
91 |
92 | * update rio-tiler requirement
93 | * add `reader` option
94 |
95 | ## 0.4.1 (2022-02-14)
96 |
97 | * update Fastapi requirement
98 | * use WarpedVRT to get dataset bounds in epsg:4326
99 |
100 | ## 0.4.0 (2021-12-13)
101 |
102 | * update rio-tiler's version requirement
103 | * add more information about the raster in the Viz web page (author @drnextgis, https://github.com/developmentseed/tilebench/pull/14)
104 | * fix bug for latest GDAL/rasterio version
105 | * add default STAMEN basemap in *viz* and remove mapbox token/style options.
106 | * update fastapi requirement
107 |
108 | ## 0.3.0 (2021-03-05)
109 |
110 | * add `exclude_paths` options in `VSIStatsMiddleware` to exclude some endpoints (author @drnextgis, https://github.com/developmentseed/tilebench/pull/10)
111 | * renamed `ressources` to `resources`
112 |
113 | ## 0.2.1 (2021-02-19)
114 |
115 | * fix typo in UI
116 |
117 | ## 0.2.0 (2021-01-28)
118 |
119 | * add warp-kernels in output in `profile` CLI
120 | * add rasterio/curl stdout in output
121 | * add dataread time in Viz
122 |
123 | ## 0.1.1 (2021-01-27)
124 |
125 | * update requirements
126 |
127 | ## 0.1.0 (2021-01-04)
128 |
129 | * add web UI for VSI stats visualization
130 | * add starlette middleware
131 |
132 | ## 0.0.2 (2020-12-15)
133 |
134 | * Update for rio-tiler==2.0.0rc3
135 |
136 | ## 0.1.0 (2020-07-13)
137 |
138 | * Initial release
139 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Development - Contributing
2 |
3 | Issues and pull requests are more than welcome: https://github.com/developmentseed/tilebench/issues
4 |
5 | **dev install**
6 |
7 | ```bash
8 | git clone https://github.com/developmentseed/tilebench.git
9 | cd tilebench
10 | python -m pip install -e ".[dev,test]"
11 | ```
12 |
13 | You can then run the tests with the following command:
14 |
15 | ```sh
16 | python -m pytest --cov tilebench --cov-report term-missing -s -vv
17 | ```
18 |
19 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code.
20 |
21 | ```bash
22 | $ pre-commit install
23 | ```
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG PYTHON_VERSION=3.11
2 |
3 | FROM bitnami/python:${PYTHON_VERSION}
4 | RUN apt update && apt upgrade -y \
5 | && rm -rf /var/lib/apt/lists/*
6 |
7 | RUN apt-get update
8 |
9 | COPY tilebench tilebench
10 | COPY pyproject.toml pyproject.toml
11 | COPY README.md README.md
12 | COPY CHANGES.md CHANGES.md
13 | COPY LICENSE LICENSE
14 |
15 | RUN pip install . --no-cache-dir --upgrade
16 |
17 | ENV GDAL_INGESTED_BYTES_AT_OPEN 32768
18 | ENV GDAL_DISABLE_READDIR_ON_OPEN EMPTY_DIR
19 | ENV GDAL_HTTP_MERGE_CONSECUTIVE_RANGES YES
20 | ENV GDAL_HTTP_MULTIPLEX YES
21 | ENV GDAL_HTTP_VERSION 2
22 | ENV VSI_CACHE TRUE
23 | ENV VSI_CACHE_SIZE 536870912
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Development Seed
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tilebench
2 |
3 |
4 |
5 |
6 |
7 | Inspect HEAD/LIST/GET requests within Rasterio
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ---
28 |
29 | **Source Code**: https://github.com/developmentseed/tilebench
30 |
31 | ---
32 |
33 |
34 | Inspect HEAD/GET requests withing Rasterio.
35 |
36 | Note: In GDAL 3.2, logging capabilities for /vsicurl, /vsis3 and the like was added (ref: https://github.com/OSGeo/gdal/pull/2742).
37 |
38 | ## Install
39 |
40 | You can install `tilebench` using pip
41 |
42 | ```bash
43 | $ python -m pip install -U pip
44 | $ python -m pip install -U tilebench
45 | ```
46 |
47 | or install from source:
48 |
49 | ```bash
50 | git clone https://github.com/developmentseed/tilebench.git
51 | cd tilebench
52 |
53 | python -m pip install -U pip
54 | python -m pip install -e .
55 | ```
56 |
57 | ## API
58 |
59 | ```python
60 | from tilebench import profile
61 | import rasterio
62 |
63 | @profile()
64 | def info(src_path: str):
65 | with rasterio.open(src_path) as src_dst:
66 | return src_dst.meta
67 |
68 | meta = info("https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif")
69 |
70 | > 2023-10-18T23:00:11.184745+0200 | TILEBENCH | {"HEAD": {"count": 1}, "GET": {"count": 1, "bytes": 32768, "ranges": ["0-32767"]}, "Timing": 0.7379939556121826}
71 | ```
72 |
73 | ```python
74 | from tilebench import profile
75 | from rio_tiler.io import Reader
76 |
77 | @profile()
78 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
79 | with Reader(src_path) as cog:
80 | return cog.tile(x, y, z, tilesize=tilesize)
81 |
82 | img = _read_tile(
83 | "https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif",
84 | 9114,
85 | 13216,
86 | 15,
87 | )
88 |
89 | > 2023-10-18T23:01:00.572263+0200 | TILEBENCH | {"HEAD": {"count": 1}, "GET": {"count": 2, "bytes": 409600, "ranges": ["0-32767", "32768-409599"]}, "Timing": 1.0749869346618652}
90 | ```
91 |
92 | ## Command Line Interface (CLI)
93 |
94 | ```
95 | $ tilebench --help
96 | Usage: tilebench [OPTIONS] COMMAND [ARGS]...
97 |
98 | Command line interface for the tilebench Python package.
99 |
100 | Options:
101 | --help Show this message and exit.
102 |
103 | Commands:
104 | get-zooms Get Mercator Zoom levels.
105 | profile Profile COGReader Mercator Tile read.
106 | random Get random tile.
107 | viz WEB UI to visualize VSI statistics for a web mercator tile request
108 | ```
109 |
110 | #### Examples
111 | ```
112 | $ tilebench get-zooms https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif | jq
113 | {
114 | "minzoom": 14,
115 | "maxzoom": 19
116 | }
117 |
118 | $ tilebench random https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif --zoom 15
119 | 15-9114-13215
120 |
121 | $ tilebench profile https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif --tile 15-9114-13215 --config GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR | jq
122 | {
123 | "HEAD": {
124 | "count": 1
125 | },
126 | "GET": {
127 | "count": 2,
128 | "bytes": 409600,
129 | "ranges": [
130 | "0-32767",
131 | "32768-409599"
132 | ]
133 | },
134 | "Timing": 0.9715230464935303
135 | }
136 |
137 | $ tilebench profile https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif --tile 15-9114-13215 --config GDAL_DISABLE_READDIR_ON_OPEN=FALSE | jq
138 | {
139 | "HEAD": {
140 | "count": 8
141 | },
142 | "GET": {
143 | "count": 3,
144 | "bytes": 409600,
145 | "ranges": [
146 | "0-32767",
147 | "32768-409599"
148 | ]
149 | },
150 | "Timing": 2.1837549209594727
151 | }
152 | ```
153 |
154 |
155 | ## Starlette Middleware
156 |
157 | **Warning**: This is highly experimental and should not be used in production (https://github.com/developmentseed/tilebench/issues/6)
158 |
159 | In addition of the `viz` CLI we added a starlette middleware to easily integrate VSI statistics in your web services.
160 |
161 | ```python
162 | from fastapi import FastAPI
163 |
164 | from tilebench.middleware import VSIStatsMiddleware
165 |
166 | app = FastAPI()
167 | app.add_middleware(VSIStatsMiddleware)
168 | ```
169 |
170 | The middleware will add a `vsi-stats` entry in the response `headers` in form of:
171 |
172 | ```
173 | vsi-stats: list;count=1, head;count=1, get;count=2;size=196608, ranges; values=0-65535|65536-196607
174 | ```
175 |
176 | Some paths may be excluded from being handeld by the middleware by the `exclude_paths` argument:
177 |
178 | ```python
179 | app.add_middleware(VSIStatsMiddleware, exclude_paths=["/foo", "/bar"])
180 | ```
181 |
182 | ## GDAL config options
183 |
184 | - **CPL_TIMESTAMP**: Add timings on GDAL Logs
185 | - **GDAL_DISABLE_READDIR_ON_OPEN**: Allow or Disable listing of files in the directory (e.g external overview)
186 | - **GDAL_INGESTED_BYTES_AT_OPEN**: Control how many bytes GDAL will ingest when opening a dataset (useful when a file has a big header)
187 | - **CPL_VSIL_CURL_ALLOWED_EXTENSIONS**: Limit valid external files
188 | - **GDAL_CACHEMAX**: Cache size
189 | - **GDAL_HTTP_MERGE_CONSECUTIVE_RANGES**
190 | - **VSI_CACHE**
191 | - **VSI_CACHE_SIZE**
192 |
193 | See the full list at https://gdal.org/user/configoptions.html
194 |
195 | ## Internal tiles Vs Mercator grid
196 |
197 | ```
198 | $ tilebench viz https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif --config GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
199 | ```
200 |
201 | 
202 |
203 | Blue lines represent the mercator grid for a specific zoom level and the red lines represent the internal tiles bounds
204 |
205 | We can then click on a mercator tile and see how much requests GDAL/RASTERIO does.
206 |
207 | 
208 |
209 | ## Docker
210 |
211 | Ready to use docker image can be found on Github registry.
212 |
213 | - https://github.com/developmentseed/tilebench/pkgs/container/tilebench
214 |
215 | ```bash
216 | docker run \
217 | --volume "$PWD":/data \
218 | --platform linux/amd64 \
219 | --rm -it -p 8080:8080 ghcr.io/developmentseed/tilebench:latest \
220 | tilebench viz --host 0.0.0.0 https://noaa-eri-pds.s3.us-east-1.amazonaws.com/2020_Nashville_Tornado/20200307a_RGB/20200307aC0865700w360900n.tif
221 | ```
222 |
223 | ## Contribution & Development
224 |
225 | See [CONTRIBUTING.md](https://github.com/developmentseed/tilebench/blob/main/CONTRIBUTING.md)
226 |
227 | ## License
228 |
229 | See [LICENSE](https://github.com//developmentseed/tilebench/blob/main/LICENSE)
230 |
231 | ## Authors
232 |
233 | See [contributors](https://github.com/developmentseed/tilebench/graphs/contributors) for a listing of individual contributors.
234 |
235 | ## Changes
236 |
237 | See [CHANGES.md](https://github.com/developmentseed/tilebench/blob/main/CHANGES.md).
238 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "tilebench"
3 | description = "Inspect HEAD/LIST/GET requests withing Rasterio"
4 | requires-python = ">=3.8"
5 | license = {file = "LICENSE"}
6 | authors = [
7 | {name = "Vincent Sarago", email = "vincent@developmentseed.com"},
8 | ]
9 | classifiers = [
10 | "Intended Audience :: Information Technology",
11 | "Intended Audience :: Science/Research",
12 | "License :: OSI Approved :: MIT License",
13 | "Programming Language :: Python :: 3",
14 | "Programming Language :: Python :: 3 :: Only",
15 | "Programming Language :: Python :: 3.9",
16 | "Programming Language :: Python :: 3.10",
17 | "Programming Language :: Python :: 3.11",
18 | "Programming Language :: Python :: 3.12",
19 | "Programming Language :: Python :: 3.13",
20 | "Topic :: Scientific/Engineering :: GIS",
21 | ]
22 | dynamic = ["version", "readme"]
23 | dependencies = [
24 | "fastapi>=0.100.0",
25 | "jinja2>=3.0,<4.0.0",
26 | "loguru",
27 | "rasterio>=1.3.8",
28 | "rio-tiler>=7.0,<8.0",
29 | "uvicorn[standard]",
30 | ]
31 |
32 | [project.optional-dependencies]
33 | test = [
34 | "pytest",
35 | "pytest-cov",
36 | "pytest-asyncio",
37 | "requests",
38 | "vsifile",
39 | ]
40 | dev = [
41 | "pre-commit",
42 | "bump-my-version",
43 | ]
44 |
45 | [project.urls]
46 | Homepage = 'https://github.com/developmentseed/tilebench'
47 | Issues = "https://github.com/developmentseed/tilebench/issues"
48 | Source = "https://github.com/developmentseed/tilebench"
49 |
50 | [project.scripts]
51 | tilebench = "tilebench.scripts.cli:cli"
52 |
53 | [tool.hatch.metadata.hooks.fancy-pypi-readme]
54 | content-type = 'text/markdown'
55 | # construct the PyPI readme from README.md and HISTORY.md
56 | fragments = [
57 | {path = "README.md"},
58 | {text = "\n## Changelog\n\n"},
59 | {path = "CHANGES.md"},
60 | ]
61 | # convert GitHUB issue/PR numbers and handles to links
62 | substitutions = [
63 | {pattern = '(\s+)#(\d+)', replacement = '\1[#\2](https://github.com/developmentseed/tilebench/issues/\2)'},
64 | {pattern = '(\s+)@([\w\-]+)', replacement = '\1[@\2](https://github.com/\2)'},
65 | {pattern = '@@', replacement = '@'},
66 | ]
67 |
68 | [tool.hatch.version]
69 | path = "tilebench/__init__.py"
70 |
71 | [tool.hatch.build.targets.sdist]
72 | exclude = [
73 | "/tests",
74 | "Dockerfile",
75 | ".pytest_cache",
76 | ".history",
77 | ".github",
78 | ".bumpversion.cfg",
79 | ".flake8",
80 | ".gitignore",
81 | ".pre-commit-config.yaml",
82 | ]
83 |
84 | [build-system]
85 | requires = ["hatchling", "hatch-fancy-pypi-readme>=22.5.0"]
86 | build-backend = "hatchling.build"
87 |
88 | [tool.isort]
89 | profile = "black"
90 | known_first_party = ["tilebench"]
91 | known_third_party = ["rasterio", "rio_tiler", "morecantile", "geojson_pydantic", "fastapi"]
92 | default_section = "THIRDPARTY"
93 |
94 | [tool.mypy]
95 | no_strict_optional = true
96 |
97 | [tool.ruff]
98 | line-length = 90
99 |
100 | [tool.ruff.lint]
101 | select = [
102 | "D1", # pydocstyle errors
103 | "E", # pycodestyle errors
104 | "W", # pycodestyle warnings
105 | "F", # flake8
106 | "C", # flake8-comprehensions
107 | "B", # flake8-bugbear
108 | ]
109 | ignore = [
110 | "E501", # line too long, handled by black
111 | "B008", # do not perform function calls in argument defaults
112 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10
113 | ]
114 |
115 | [tool.ruff.lint.mccabe]
116 | max-complexity = 14
117 |
118 |
119 | [tool.bumpversion]
120 | current_version = "0.16.0"
121 | search = "{current_version}"
122 | replace = "{new_version}"
123 | regex = false
124 | tag = true
125 | commit = true
126 | tag_name = "{new_version}"
127 |
128 | [[tool.bumpversion.files]]
129 | filename = "tilebench/__init__.py"
130 | search = '__version__ = "{current_version}"'
131 | replace = '__version__ = "{new_version}"'
132 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """tilebench test."""
2 |
--------------------------------------------------------------------------------
/tests/fixtures/WGS1984Quad.json:
--------------------------------------------------------------------------------
1 | {"title":"EPSG:4326 for the World","id":"WorldCRS84Quad","uri":"http://www.opengis.net/def/tilematrixset/OGC/1.0/WorldCRS84Quad","orderedAxes":["Lat","Lon"],"crs":"http://www.opengis.net/def/crs/EPSG/0/4326","wellKnownScaleSet":"http://www.opengis.net/def/wkss/OGC/1.0/GoogleCRS84Quad","tileMatrices":[{"id":"0","scaleDenominator":279541132.014358,"cellSize":0.703125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":2,"matrixHeight":1},{"id":"1","scaleDenominator":139770566.007179,"cellSize":0.3515625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":4,"matrixHeight":2},{"id":"2","scaleDenominator":69885283.0035897,"cellSize":0.17578125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":8,"matrixHeight":4},{"id":"3","scaleDenominator":34942641.5017948,"cellSize":0.087890625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":16,"matrixHeight":8},{"id":"4","scaleDenominator":17471320.7508974,"cellSize":0.0439453125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":32,"matrixHeight":16},{"id":"5","scaleDenominator":8735660.37544871,"cellSize":0.02197265625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":64,"matrixHeight":32},{"id":"6","scaleDenominator":4367830.18772435,"cellSize":0.010986328125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":128,"matrixHeight":64},{"id":"7","scaleDenominator":2183915.09386217,"cellSize":0.0054931640625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":256,"matrixHeight":128},{"id":"8","scaleDenominator":1091957.54693108,"cellSize":0.00274658203125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":512,"matrixHeight":256},{"id":"9","scaleDenominator":545978.773465544,"cellSize":0.001373291015625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":1024,"matrixHeight":512},{"id":"10","scaleDenominator":272989.386732772,"cellSize":0.0006866455078125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":2048,"matrixHeight":1024},{"id":"11","scaleDenominator":136494.693366386,"cellSize":0.00034332275390625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":4096,"matrixHeight":2048},{"id":"12","scaleDenominator":68247.346683193,"cellSize":0.000171661376953125,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":8192,"matrixHeight":4096},{"id":"13","scaleDenominator":34123.6733415964,"cellSize":0.0000858306884765625,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":16384,"matrixHeight":8192},{"id":"14","scaleDenominator":17061.8366707982,"cellSize":0.0000429153442382812,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":32768,"matrixHeight":16384},{"id":"15","scaleDenominator":8530.91833539913,"cellSize":0.0000214576721191406,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":65536,"matrixHeight":32768},{"id":"16","scaleDenominator":4265.45916769956,"cellSize":0.0000107288360595703,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":131072,"matrixHeight":65536},{"id":"17","scaleDenominator":2132.72958384978,"cellSize":5.36441802978515e-6,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":262144,"matrixHeight":131072},{"id":"18","scaleDenominator":1066.36479192489,"cellSize":2.68220901489258e-6,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":524288,"matrixHeight":262144},{"id":"19","scaleDenominator":533.182395962445,"cellSize":1.34110450744629e-6,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":1048576,"matrixHeight":524288},{"id":"20","scaleDenominator":266.591197981222,"cellSize":6.7055225372314e-7,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":2097152,"matrixHeight":1048576},{"id":"21","scaleDenominator":133.295598990611,"cellSize":3.3527612686157e-7,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":4194304,"matrixHeight":2097152},{"id":"22","scaleDenominator":66.6477994953056,"cellSize":1.6763806343079e-7,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":8388608,"matrixHeight":4194304},{"id":"23","scaleDenominator":33.3238997476528,"cellSize":8.381903171539e-8,"cornerOfOrigin":"topLeft","pointOfOrigin":[90.0,-180.0],"tileWidth":256,"tileHeight":256,"matrixWidth":16777216,"matrixHeight":8388608}]}
2 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | """Test CLI."""
2 |
3 | import json
4 | import os
5 | from unittest.mock import patch
6 |
7 | from click.testing import CliRunner
8 |
9 | from tilebench.scripts.cli import cli
10 |
11 | COG_PATH = "https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif"
12 |
13 | TMS = os.path.join(os.path.dirname(__file__), "fixtures", "WGS1984Quad.json")
14 |
15 |
16 | def test_profile():
17 | """Should work as expected."""
18 | runner = CliRunner()
19 |
20 | result = runner.invoke(cli, ["profile", COG_PATH])
21 | assert not result.exception
22 | assert result.exit_code == 0
23 | log = json.loads(result.output)
24 | assert ["HEAD", "GET", "Timing"] == list(log)
25 | # Make sure we didn't cache any request when `--tile` is not provided
26 | assert "0-" in log["GET"]["ranges"][0]
27 |
28 | result = runner.invoke(
29 | cli,
30 | [
31 | "profile",
32 | COG_PATH,
33 | "--tilesize",
34 | 512,
35 | "--zoom",
36 | 11,
37 | "--reader",
38 | "rio_tiler.io.Reader",
39 | ],
40 | )
41 | assert not result.exception
42 | assert result.exit_code == 0
43 | log = json.loads(result.output)
44 | assert ["HEAD", "GET", "Timing"] == list(log)
45 |
46 | result = runner.invoke(
47 | cli, ["profile", COG_PATH, "--tilesize", 512, "--tile", "16-18229-26433"]
48 | )
49 | assert not result.exception
50 | assert result.exit_code == 0
51 | log = json.loads(result.output)
52 | assert ["HEAD", "GET", "Timing"] == list(log)
53 |
54 | result = runner.invoke(
55 | cli, ["profile", COG_PATH, "--add-kernels", "--add-stdout", "--add-cprofile"]
56 | )
57 | assert not result.exception
58 | assert result.exit_code == 0
59 | log = json.loads(result.output)
60 | assert [
61 | "HEAD",
62 | "GET",
63 | "WarpKernels",
64 | "Timing",
65 | "cprofile",
66 | "logs",
67 | ] == list(log)
68 |
69 |
70 | def test_get_zoom():
71 | """Should work as expected."""
72 | runner = CliRunner()
73 |
74 | result = runner.invoke(cli, ["get-zooms", COG_PATH])
75 | assert not result.exception
76 | assert result.exit_code == 0
77 | log = json.loads(result.output)
78 | assert ["minzoom", "maxzoom"] == list(log)
79 | assert log["minzoom"] == 14
80 | assert log["maxzoom"] == 19
81 |
82 | result = runner.invoke(
83 | cli, ["get-zooms", COG_PATH, "--reader", "rio_tiler.io.Reader"]
84 | )
85 | assert not result.exception
86 | assert result.exit_code == 0
87 | log = json.loads(result.output)
88 | assert ["minzoom", "maxzoom"] == list(log)
89 |
90 |
91 | def test_random():
92 | """Should work as expected."""
93 | runner = CliRunner()
94 |
95 | result = runner.invoke(cli, ["random", COG_PATH])
96 | assert not result.exception
97 | assert result.exit_code == 0
98 | assert "-" in result.output
99 |
100 | result = runner.invoke(
101 | cli, ["random", COG_PATH, "--zoom", 14, "--reader", "rio_tiler.io.Reader"]
102 | )
103 | assert not result.exception
104 | assert result.exit_code == 0
105 | assert "14-" in result.output
106 |
107 |
108 | @patch("click.launch")
109 | def test_viz(launch):
110 | """Should work as expected."""
111 | runner = CliRunner()
112 |
113 | launch.return_value = True
114 |
115 | result = runner.invoke(cli, ["random", COG_PATH])
116 | assert not result.exception
117 | assert result.exit_code == 0
118 | assert "-" in result.output
119 |
120 | result = runner.invoke(
121 | cli, ["random", COG_PATH, "--zoom", 14, "--reader", "rio_tiler.io.Reader"]
122 | )
123 | assert not result.exception
124 | assert result.exit_code == 0
125 | assert "14-" in result.output
126 |
127 |
128 | def test_tms():
129 | """Should work as expected."""
130 | runner = CliRunner()
131 |
132 | result = runner.invoke(cli, ["profile", COG_PATH, "--tms", TMS])
133 | assert not result.exception
134 | assert result.exit_code == 0
135 | log = json.loads(result.output)
136 | assert ["HEAD", "GET", "Timing"] == list(log)
137 | # Make sure we didn't cache any request when `--tile` is not provided
138 | assert "0-" in log["GET"]["ranges"][0]
139 |
140 | result = runner.invoke(cli, ["get-zooms", COG_PATH, "--tms", TMS])
141 | assert not result.exception
142 | assert result.exit_code == 0
143 | log = json.loads(result.output)
144 | assert ["minzoom", "maxzoom"] == list(log)
145 | assert log["minzoom"] == 13
146 | assert log["maxzoom"] == 18
147 |
148 | result = runner.invoke(cli, ["random", COG_PATH, "--tms", TMS])
149 | assert not result.exception
150 | assert result.exit_code == 0
151 | assert "-" in result.output
152 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | """Tests for tilebench."""
2 |
3 | import rasterio
4 | from fastapi import FastAPI
5 | from rio_tiler.io import Reader
6 | from starlette.testclient import TestClient
7 | from vsifile.rasterio import opener
8 |
9 | from tilebench.middleware import NoCacheMiddleware, VSIStatsMiddleware
10 |
11 | COG_PATH = "https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif"
12 |
13 |
14 | def test_middleware():
15 | """Simple test."""
16 | app = FastAPI()
17 | app.add_middleware(NoCacheMiddleware)
18 | app.add_middleware(VSIStatsMiddleware, config={}, exclude_paths=["/skip"])
19 |
20 | @app.get("/info")
21 | def head():
22 | """Get info."""
23 | with Reader(COG_PATH) as cog:
24 | cog.info()
25 | return "I got info"
26 |
27 | @app.get("/tile")
28 | def tile():
29 | """Read tile."""
30 | with Reader(COG_PATH) as cog:
31 | cog.tile(36460, 52866, 17)
32 | return "I got tile"
33 |
34 | @app.get("/skip")
35 | def skip():
36 | return "I've been skipped"
37 |
38 | with TestClient(app) as client:
39 | response = client.get("/info")
40 | assert response.status_code == 200
41 | assert response.headers["content-type"] == "application/json"
42 | assert response.headers["Cache-Control"] == "no-cache"
43 | assert response.headers["VSI-Stats"]
44 | stats = response.headers["VSI-Stats"]
45 | assert "head;count=" in stats
46 | assert "get;count=" in stats
47 |
48 | response = client.get("/tile")
49 | assert response.status_code == 200
50 | assert response.headers["content-type"] == "application/json"
51 | assert response.headers["VSI-Stats"]
52 | stats = response.headers["VSI-Stats"]
53 | assert "head;count=" in stats
54 | assert "get;count=" in stats
55 |
56 | response = client.get("/skip")
57 | assert response.status_code == 200
58 | assert response.headers["content-type"] == "application/json"
59 | assert "VSI-Stats" not in response.headers
60 |
61 |
62 | def test_middleware_vsifile():
63 | """Simple test."""
64 | app = FastAPI()
65 | app.add_middleware(NoCacheMiddleware)
66 | app.add_middleware(
67 | VSIStatsMiddleware, config={}, exclude_paths=["/skip"], io="vsifile"
68 | )
69 |
70 | @app.get("/info")
71 | def head():
72 | """Get info."""
73 | with rasterio.open(COG_PATH, opener=opener) as src:
74 | with Reader(None, dataset=src) as cog:
75 | cog.info()
76 | return "I got info"
77 |
78 | @app.get("/tile")
79 | def tile():
80 | """Read tile."""
81 | with rasterio.open(COG_PATH, opener=opener) as src:
82 | with Reader(None, dataset=src) as cog:
83 | cog.tile(36460, 52866, 17)
84 | return "I got tile"
85 |
86 | @app.get("/skip")
87 | def skip():
88 | return "I've been skipped"
89 |
90 | with TestClient(app) as client:
91 | response = client.get("/info")
92 | assert response.status_code == 200
93 | assert response.headers["content-type"] == "application/json"
94 | assert response.headers["Cache-Control"] == "no-cache"
95 | assert response.headers["VSI-Stats"]
96 | stats = response.headers["VSI-Stats"]
97 | assert "head;count=" in stats
98 | assert "get;count=" in stats
99 |
100 | response = client.get("/tile")
101 | assert response.status_code == 200
102 | assert response.headers["content-type"] == "application/json"
103 | assert response.headers["VSI-Stats"]
104 | stats = response.headers["VSI-Stats"]
105 | assert "head;count=" in stats
106 | assert "get;count=" in stats
107 |
108 | response = client.get("/skip")
109 | assert response.status_code == 200
110 | assert response.headers["content-type"] == "application/json"
111 | assert "VSI-Stats" not in response.headers
112 |
--------------------------------------------------------------------------------
/tests/test_reader.py:
--------------------------------------------------------------------------------
1 | """Tests for tilebench."""
2 |
3 | import rasterio
4 | from rio_tiler.io import Reader
5 | from vsifile.rasterio import opener
6 |
7 | from tilebench import profile as profiler
8 |
9 | COG_PATH = "https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif"
10 |
11 |
12 | def test_simple():
13 | """Simple test."""
14 |
15 | @profiler()
16 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
17 | with Reader(src_path) as cog:
18 | return cog.tile(x, y, z, tilesize=tilesize)
19 |
20 | data, mask = _read_tile(COG_PATH, 36460, 52866, 17)
21 | assert data.shape
22 | assert mask.shape
23 |
24 |
25 | def test_output():
26 | """Checkout profile output."""
27 |
28 | @profiler(
29 | kernels=True,
30 | add_to_return=True,
31 | quiet=True,
32 | config={"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"},
33 | )
34 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
35 | with Reader(src_path) as cog:
36 | return cog.tile(x, y, z, tilesize=tilesize)
37 |
38 | (data, mask), stats = _read_tile(COG_PATH, 36460, 52866, 17)
39 | assert data.shape
40 | assert mask.shape
41 | assert stats
42 | assert stats.get("HEAD")
43 | assert stats.get("GET")
44 | assert stats.get("Timing")
45 | assert stats.get("WarpKernels")
46 |
47 |
48 | def test_vsifile():
49 | """Checkout profile output."""
50 |
51 | @profiler(
52 | kernels=True,
53 | add_to_return=True,
54 | quiet=True,
55 | config={"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"},
56 | io="vsifile",
57 | )
58 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
59 | with rasterio.open(src_path, opener=opener) as src:
60 | with Reader(None, dataset=src) as cog:
61 | return cog.tile(x, y, z, tilesize=tilesize)
62 |
63 | (data, mask), stats = _read_tile(COG_PATH, 36460, 52866, 17)
64 | assert data.shape
65 | assert mask.shape
66 | assert stats
67 | assert "HEAD" in stats
68 | assert stats.get("GET")
69 | assert stats.get("Timing")
70 | assert "WarpKernels" in stats
71 |
--------------------------------------------------------------------------------
/tests/test_tilebench.py:
--------------------------------------------------------------------------------
1 | """Test profiler with S3 and HTTPS files.
2 |
3 | NOTE: while not in GDAL>=3.10 the number of GET/Head requests might not be right
4 | see: https://github.com/vincentsarago/vsifile/issues/13#issuecomment-2683310594
5 |
6 | """
7 |
8 | import pytest
9 | from rio_tiler.io import Reader
10 |
11 | from tilebench import profile as profiler
12 |
13 |
14 | @pytest.mark.parametrize(
15 | "src_path,head,get",
16 | [
17 | (
18 | "s3://sentinel-cogs/sentinel-s2-l2a-cogs/15/T/VK/2023/10/S2B_15TVK_20231008_0_L2A/TCI.tif",
19 | 0,
20 | 3,
21 | ),
22 | (
23 | "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/15/T/VK/2023/10/S2B_15TVK_20231008_0_L2A/TCI.tif",
24 | 1,
25 | 3,
26 | ),
27 | ],
28 | )
29 | @pytest.mark.xfail
30 | def test_profiler(src_path, head, get):
31 | """Test profiler."""
32 | config = {
33 | "AWS_NO_SIGN_REQUEST": True,
34 | "AWS_DEFAULT_REGION": "us-west-2",
35 | "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
36 | }
37 |
38 | @profiler(
39 | quiet=True,
40 | add_to_return=True,
41 | config=config,
42 | )
43 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
44 | with Reader(src_path) as cog:
45 | return cog.tile(x, y, z, tilesize=tilesize)
46 |
47 | (_, _), stats = _read_tile(src_path, 121, 185, 9)
48 | assert stats["HEAD"]["count"] == head
49 | assert stats["GET"]["count"] == get
50 | assert stats["GET"]["bytes"] == 386677
51 |
--------------------------------------------------------------------------------
/tests/test_viz.py:
--------------------------------------------------------------------------------
1 | """Tests for tilebench."""
2 |
3 | import attr
4 | import rasterio
5 | from rio_tiler.io import Reader
6 | from starlette.testclient import TestClient
7 | from vsifile.rasterio import opener
8 |
9 | from tilebench.viz import TileDebug
10 |
11 | COG_PATH = "https://noaa-eri-pds.s3.amazonaws.com/2022_Hurricane_Ian/20221002a_RGB/20221002aC0795145w325100n.tif"
12 |
13 |
14 | def test_viz():
15 | """Should work as expected (create TileServer object)."""
16 | app = TileDebug(
17 | src_path=COG_PATH,
18 | config={"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"},
19 | )
20 | assert app.port == 8080
21 | assert app.endpoint == "http://127.0.0.1:8080"
22 | assert app.template_url == "http://127.0.0.1:8080"
23 |
24 | with TestClient(app.app) as client:
25 | response = client.get("/tiles/17/36460/52866")
26 | assert response.status_code == 200
27 | assert response.headers["content-type"] == "application/json"
28 | assert response.headers["Cache-Control"] == "no-cache"
29 | assert response.headers["VSI-Stats"]
30 | stats = response.headers["VSI-Stats"]
31 | assert "head;count=" in stats
32 | assert "get;count=" in stats
33 |
34 | response = client.get("/info.geojson")
35 | assert response.status_code == 200
36 | assert response.headers["content-type"] == "application/geo+json"
37 | assert "VSI-Stats" not in response.headers
38 |
39 | response = client.get("/tiles.geojson?ovr_level=0")
40 | assert response.status_code == 200
41 | assert response.headers["content-type"] == "application/geo+json"
42 |
43 | response = client.get("/tiles.geojson?ovr_level=1")
44 | assert response.status_code == 200
45 | assert response.headers["content-type"] == "application/geo+json"
46 |
47 |
48 | def test_viz_vsifile():
49 | """Should work as expected (create TileServer object)."""
50 |
51 | @attr.s
52 | class VSIReader(Reader):
53 | """Rasterio Reader with VSIFILE opener."""
54 |
55 | dataset = attr.ib(default=None, init=False) # type: ignore
56 |
57 | def __attrs_post_init__(self):
58 | """Use vsifile.rasterio.opener as Python file opener."""
59 | self.dataset = self._ctx_stack.enter_context(
60 | rasterio.open(self.input, opener=opener)
61 | )
62 | super().__attrs_post_init__()
63 |
64 | app = TileDebug(
65 | src_path=COG_PATH,
66 | config={"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"},
67 | reader=VSIReader,
68 | io_backend="vsifile",
69 | )
70 | assert app.port == 8080
71 | assert app.endpoint == "http://127.0.0.1:8080"
72 | assert app.template_url == "http://127.0.0.1:8080"
73 |
74 | with TestClient(app.app) as client:
75 | response = client.get("/tiles/17/36460/52866")
76 | assert response.status_code == 200
77 | assert response.headers["content-type"] == "application/json"
78 | assert response.headers["Cache-Control"] == "no-cache"
79 | assert response.headers["VSI-Stats"]
80 | stats = response.headers["VSI-Stats"]
81 | assert "head;count=" in stats
82 | assert "get;count=" in stats
83 |
84 | response = client.get("/info.geojson")
85 | assert response.status_code == 200
86 | assert response.headers["content-type"] == "application/geo+json"
87 | assert "VSI-Stats" not in response.headers
88 |
89 | response = client.get("/tiles.geojson?ovr_level=0")
90 | assert response.status_code == 200
91 | assert response.headers["content-type"] == "application/geo+json"
92 |
93 | response = client.get("/tiles.geojson?ovr_level=1")
94 | assert response.status_code == 200
95 | assert response.headers["content-type"] == "application/geo+json"
96 |
--------------------------------------------------------------------------------
/tilebench/__init__.py:
--------------------------------------------------------------------------------
1 | """Tilebench."""
2 |
3 | __version__ = "0.16.0"
4 |
5 | import cProfile
6 | import json
7 | import logging
8 | import pstats
9 | import sys
10 | import time
11 | from io import StringIO
12 | from typing import Any, Callable, Dict, List, Optional
13 |
14 | import rasterio
15 | from loguru import logger as log
16 |
17 | fmt = "{time} | TILEBENCH | {message}"
18 | log.remove()
19 | log.add(sys.stderr, format=fmt)
20 |
21 |
22 | def parse_rasterio_io_logs(logs: List[str]) -> Dict[str, Any]:
23 | """Parse Rasterio and CURL logs."""
24 | # HEAD
25 | head_requests = len([line for line in logs if "CURL_INFO_HEADER_OUT: HEAD" in line])
26 | head_summary = {
27 | "count": head_requests,
28 | }
29 |
30 | # GET
31 | all_get_requests = len([line for line in logs if "CURL_INFO_HEADER_OUT: GET" in line])
32 |
33 | get_requests = [
34 | line for line in logs if "CURL_INFO_HEADER_IN: Content-Range: bytes" in line
35 | ]
36 | get_values = [
37 | list(
38 | map(
39 | int,
40 | get.split("CURL_INFO_HEADER_IN: Content-Range: bytes ")[1]
41 | .split("/")[0]
42 | .split("-"),
43 | )
44 | )
45 | for get in get_requests
46 | ]
47 | get_values_str = [f"{start}-{end}" for (start, end) in get_values]
48 | data_transfer = sum([j - i + 1 for i, j in get_values])
49 |
50 | get_summary = {
51 | "count": all_get_requests,
52 | "bytes": data_transfer,
53 | "ranges": get_values_str,
54 | }
55 |
56 | warp_kernel = [line.split(" ")[-2:] for line in logs if "GDALWarpKernel" in line]
57 |
58 | return {
59 | "HEAD": head_summary,
60 | "GET": get_summary,
61 | "WarpKernels": warp_kernel,
62 | }
63 |
64 |
65 | def parse_vsifile_io_logs(logs: List[str]) -> Dict[str, Any]:
66 | """Parse VSIFILE IO logs."""
67 | # HEAD
68 | head_requests = len([line for line in logs if "VSIFILE_INFO: HEAD" in line])
69 | head_summary = {
70 | "count": head_requests,
71 | }
72 |
73 | # GET
74 | all_get_requests = len([line for line in logs if "VSIFILE_INFO: GET" in line])
75 |
76 | get_requests = [line for line in logs if "VSIFILE: Downloading: " in line]
77 |
78 | get_values_str = []
79 | for get in get_requests:
80 | get_values_str.extend(get.split("VSIFILE: Downloading: ")[1].split(", "))
81 |
82 | get_values = [list(map(int, r.split("-"))) for r in get_values_str]
83 | data_transfer = sum([j - i + 1 for i, j in get_values])
84 |
85 | get_summary = {
86 | "count": all_get_requests,
87 | "bytes": data_transfer,
88 | "ranges": get_values_str,
89 | }
90 |
91 | warp_kernel = [line.split(" ")[-2:] for line in logs if "GDALWarpKernel" in line]
92 |
93 | return {
94 | "HEAD": head_summary,
95 | "GET": get_summary,
96 | "WarpKernels": warp_kernel,
97 | }
98 |
99 |
100 | def profile(
101 | kernels: bool = False,
102 | add_to_return: bool = False,
103 | quiet: bool = False,
104 | raw: bool = False,
105 | cprofile: bool = False,
106 | config: Optional[Dict] = None,
107 | io="rasterio",
108 | ):
109 | """Profiling."""
110 | if io not in ["rasterio", "vsifile"]:
111 | raise ValueError(f"Unsupported {io} IO backend")
112 |
113 | def wrapper(func: Callable):
114 | """Wrap a function."""
115 |
116 | def wrapped_f(*args, **kwargs):
117 | """Wrapped function."""
118 | io_stream = StringIO()
119 | logger = logging.getLogger(io)
120 | logger.setLevel(logging.DEBUG)
121 | handler = logging.StreamHandler(io_stream)
122 | logger.addHandler(handler)
123 |
124 | gdal_config = config or {}
125 | gdal_config.update({"CPL_DEBUG": "ON", "CPL_CURL_VERBOSE": "YES"})
126 |
127 | with rasterio.Env(**gdal_config):
128 | with Timer() as t:
129 | prof = cProfile.Profile()
130 | retval = prof.runcall(func, *args, **kwargs)
131 | profile_stream = StringIO()
132 | ps = pstats.Stats(prof, stream=profile_stream)
133 | ps.strip_dirs().sort_stats("time", "ncalls").print_stats()
134 |
135 | logger.removeHandler(handler)
136 | handler.close()
137 |
138 | logs = io_stream.getvalue().splitlines()
139 | profile_lines = [p for p in profile_stream.getvalue().splitlines() if p]
140 |
141 | results = {}
142 | if io == "vsifile":
143 | results.update(parse_vsifile_io_logs(logs))
144 | else:
145 | results.update(parse_rasterio_io_logs(logs))
146 |
147 | results["Timing"] = t.elapsed
148 |
149 | if cprofile:
150 | stats_to_print = [
151 | p for p in profile_lines[3:] if float(p.split()[1]) > 0.0
152 | ]
153 | results["cprofile"] = [profile_lines[2], *stats_to_print]
154 |
155 | if not kernels:
156 | results.pop("WarpKernels")
157 |
158 | if raw:
159 | results["logs"] = logs
160 |
161 | if not quiet:
162 | log.info(json.dumps(results))
163 |
164 | if add_to_return:
165 | return retval, results
166 |
167 | return retval
168 |
169 | return wrapped_f
170 |
171 | return wrapper
172 |
173 |
174 | # This code is copied from marblecutter
175 | # https://github.com/mojodna/marblecutter/blob/master/marblecutter/stats.py
176 | # License:
177 | # Original work Copyright 2016 Stamen Design
178 | # Modified work Copyright 2016-2017 Seth Fitzsimmons
179 | # Modified work Copyright 2016 American Red Cross
180 | # Modified work Copyright 2016-2017 Humanitarian OpenStreetMap Team
181 | # Modified work Copyright 2017 Mapzen
182 | class Timer(object):
183 | """Time a code block."""
184 |
185 | def __enter__(self):
186 | """Start timer."""
187 | self.start = time.time()
188 | return self
189 |
190 | def __exit__(self, ty, val, tb):
191 | """Stop timer."""
192 | self.end = time.time()
193 | self.elapsed = self.end - self.start
194 |
--------------------------------------------------------------------------------
/tilebench/middleware.py:
--------------------------------------------------------------------------------
1 | """Tilebench middlewares."""
2 |
3 | import logging
4 | from io import StringIO
5 | from typing import Dict, List, Optional
6 |
7 | import rasterio
8 | from starlette.datastructures import MutableHeaders
9 | from starlette.middleware.base import BaseHTTPMiddleware
10 | from starlette.requests import Request
11 | from starlette.types import ASGIApp, Message, Receive, Scope, Send
12 |
13 | from tilebench import parse_rasterio_io_logs, parse_vsifile_io_logs
14 |
15 |
16 | class VSIStatsMiddleware(BaseHTTPMiddleware):
17 | """MiddleWare to add VSI stats in response headers."""
18 |
19 | def __init__(
20 | self,
21 | app: ASGIApp,
22 | config: Optional[Dict] = None,
23 | exclude_paths: Optional[List] = None,
24 | io: str = "rasterio",
25 | ) -> None:
26 | """Init Middleware."""
27 | super().__init__(app)
28 | self.config: Dict = config or {}
29 | self.exclude_paths: List = exclude_paths or []
30 |
31 | if io not in ["rasterio", "vsifile"]:
32 | raise ValueError(f"Unsupported {io} IO backend")
33 |
34 | self.io_backend = io
35 |
36 | async def dispatch(self, request: Request, call_next):
37 | """Add VSI stats in headers."""
38 |
39 | if request.scope["path"] in self.exclude_paths:
40 | return await call_next(request)
41 |
42 | io_stream = StringIO()
43 | logger = logging.getLogger(self.io_backend)
44 | logger.setLevel(logging.DEBUG)
45 | handler = logging.StreamHandler(io_stream)
46 | logger.addHandler(handler)
47 |
48 | gdal_config = {"CPL_DEBUG": "ON", "CPL_CURL_VERBOSE": "TRUE"}
49 | with rasterio.Env(**gdal_config, **self.config):
50 | response = await call_next(request)
51 |
52 | logger.removeHandler(handler)
53 | handler.close()
54 |
55 | if io_stream:
56 | logs = io_stream.getvalue().splitlines()
57 |
58 | results = {}
59 | if self.io_backend == "vsifile":
60 | results.update(parse_vsifile_io_logs(logs))
61 | else:
62 | results.update(parse_rasterio_io_logs(logs))
63 |
64 | head_results = "head;count={count}".format(**results["HEAD"])
65 | get_results = "get;count={count};size={bytes}".format(**results["GET"])
66 | ranges_results = "ranges; values={}".format(
67 | "|".join(results["GET"]["ranges"])
68 | )
69 | response.headers["VSI-Stats"] = (
70 | f"{head_results}, {get_results}, {ranges_results}"
71 | )
72 |
73 | return response
74 |
75 |
76 | class NoCacheMiddleware:
77 | """MiddleWare to add CacheControl in response headers."""
78 |
79 | def __init__(self, app: ASGIApp) -> None:
80 | """Init Middleware."""
81 | self.app = app
82 |
83 | async def __call__(self, scope: Scope, receive: Receive, send: Send):
84 | """Handle call."""
85 | if scope["type"] != "http":
86 | await self.app(scope, receive, send)
87 | return
88 |
89 | async def send_wrapper(message: Message):
90 | """Send Message."""
91 | if message["type"] == "http.response.start":
92 | response_headers = MutableHeaders(scope=message)
93 |
94 | if (
95 | not response_headers.get("Cache-Control")
96 | and scope["method"] in ["HEAD", "GET"]
97 | and message["status"] < 500
98 | ):
99 | response_headers["Cache-Control"] = "no-cache"
100 |
101 | await send(message)
102 |
103 | await self.app(scope, receive, send_wrapper)
104 |
--------------------------------------------------------------------------------
/tilebench/resources/__init__.py:
--------------------------------------------------------------------------------
1 | """tilebench viz resources."""
2 |
--------------------------------------------------------------------------------
/tilebench/resources/responses.py:
--------------------------------------------------------------------------------
1 | """Common response models."""
2 |
3 | from starlette.responses import JSONResponse, Response
4 |
5 |
6 | class GeoJSONResponse(JSONResponse):
7 | """GeoJSON Response."""
8 |
9 | media_type = "application/geo+json"
10 |
11 |
12 | class PNGResponse(Response):
13 | """GeoJSON Response."""
14 |
15 | media_type = "image/png"
16 |
--------------------------------------------------------------------------------
/tilebench/scripts/__init__.py:
--------------------------------------------------------------------------------
1 | """tilebench cli."""
2 |
--------------------------------------------------------------------------------
/tilebench/scripts/cli.py:
--------------------------------------------------------------------------------
1 | """tilebench CLI."""
2 |
3 | import importlib
4 | import json
5 | import warnings
6 | from random import randint, sample
7 |
8 | import click
9 | import morecantile
10 | import rasterio
11 | from loguru import logger as log
12 | from rasterio._path import _parse_path as parse_path
13 | from rasterio.rio import options
14 | from rio_tiler.io import BaseReader, MultiBandReader, MultiBaseReader, Reader
15 |
16 | from tilebench import profile as profiler
17 | from tilebench.viz import TileDebug
18 |
19 | default_tms = morecantile.tms.get("WebMercatorQuad")
20 |
21 |
22 | def options_to_dict(ctx, param, value):
23 | """
24 | click callback to validate `--opt KEY1=VAL1 --opt KEY2=VAL2` and collect
25 | in a dictionary like the one below, which is what the CLI function receives.
26 | If no value or `None` is received then an empty dictionary is returned.
27 |
28 | {
29 | 'KEY1': 'VAL1',
30 | 'KEY2': 'VAL2'
31 | }
32 |
33 | Note: `==VAL` breaks this as `str.split('=', 1)` is used.
34 | """
35 |
36 | if not value:
37 | return {}
38 | else:
39 | out = {}
40 | for pair in value:
41 | if "=" not in pair:
42 | raise click.BadParameter(f"Invalid syntax for KEY=VAL arg: {pair}")
43 | else:
44 | k, v = pair.split("=", 1)
45 | out[k] = v
46 |
47 | return out
48 |
49 |
50 | # The CLI command group.
51 | @click.group(help="Command line interface for the tilebench Python package.")
52 | def cli():
53 | """Execute the main morecantile command."""
54 |
55 |
56 | @cli.command()
57 | @options.file_in_arg
58 | @click.option("--tile", type=str)
59 | @click.option("--tilesize", type=int, default=256)
60 | @click.option("--zoom", type=int)
61 | @click.option(
62 | "--add-kernels",
63 | is_flag=True,
64 | default=False,
65 | help="Add GDAL WarpKernels to the output.",
66 | )
67 | @click.option(
68 | "--add-stdout",
69 | is_flag=True,
70 | default=False,
71 | help="Print standard outputs.",
72 | )
73 | @click.option(
74 | "--add-cprofile",
75 | is_flag=True,
76 | default=False,
77 | help="Print cProfile stats.",
78 | )
79 | @click.option(
80 | "--reader",
81 | type=str,
82 | help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`",
83 | )
84 | @click.option(
85 | "--tms",
86 | help="Path to TileMatrixSet JSON file.",
87 | type=click.Path(),
88 | )
89 | @click.option(
90 | "--config",
91 | "config",
92 | metavar="NAME=VALUE",
93 | multiple=True,
94 | callback=options._cb_key_val,
95 | help="GDAL configuration options.",
96 | )
97 | @click.option(
98 | "--reader-params",
99 | "-p",
100 | "reader_params",
101 | metavar="NAME=VALUE",
102 | multiple=True,
103 | callback=options_to_dict,
104 | help="Reader Options.",
105 | )
106 | @click.option(
107 | "--io",
108 | "io_backend",
109 | type=click.Choice(["vsifile", "rasterio"], case_sensitive=True),
110 | help="IO Backend Options.",
111 | default="rasterio",
112 | )
113 | def profile(
114 | input,
115 | tile,
116 | tilesize,
117 | zoom,
118 | add_kernels,
119 | add_stdout,
120 | add_cprofile,
121 | reader,
122 | tms,
123 | config,
124 | reader_params,
125 | io_backend,
126 | ):
127 | """Profile Reader Tile read."""
128 | tilematrixset = default_tms
129 | if tms:
130 | with open(tms, "r") as f:
131 | tilematrixset = morecantile.TileMatrixSet(**json.load(f))
132 |
133 | if reader:
134 | module, classname = reader.rsplit(".", 1)
135 | reader = getattr(importlib.import_module(module), classname) # noqa
136 | if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)):
137 | warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1)
138 |
139 | DstReader = reader or Reader
140 |
141 | if not tile:
142 | with rasterio.Env(CPL_VSIL_CURL_NON_CACHED=parse_path(input).as_vsi()):
143 | with Reader(input, tms=tilematrixset, **reader_params) as cog:
144 | if zoom is None:
145 | zoom = randint(cog.minzoom, cog.maxzoom)
146 |
147 | w, s, e, n = cog.get_geographic_bounds(
148 | tilematrixset.rasterio_geographic_crs
149 | )
150 | # Truncate BBox to the TMS bounds
151 | w = max(tilematrixset.bbox.left, w)
152 | s = max(tilematrixset.bbox.bottom, s)
153 | e = min(tilematrixset.bbox.right, e)
154 | n = min(tilematrixset.bbox.top, n)
155 |
156 | ul_tile = tilematrixset.tile(w, n, zoom)
157 | lr_tile = tilematrixset.tile(e, s, zoom)
158 | extrema = {
159 | "x": {"min": ul_tile.x, "max": lr_tile.x + 1},
160 | "y": {"min": ul_tile.y, "max": lr_tile.y + 1},
161 | }
162 |
163 | tile_x = sample(range(extrema["x"]["min"], extrema["x"]["max"]), 1)[0]
164 | tile_y = sample(range(extrema["y"]["min"], extrema["y"]["max"]), 1)[0]
165 | tile_z = zoom
166 | log.debug(f"reading tile: {tile_z}-{tile_x}-{tile_y}")
167 | else:
168 | tile_z, tile_x, tile_y = list(map(int, tile.split("-")))
169 |
170 | @profiler(
171 | kernels=add_kernels,
172 | quiet=True,
173 | add_to_return=True,
174 | raw=add_stdout,
175 | cprofile=add_cprofile,
176 | config=config,
177 | io=io_backend,
178 | )
179 | def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256):
180 | with DstReader(src_path, tms=tilematrixset, **reader_params) as cog:
181 | return cog.tile(x, y, z, tilesize=tilesize)
182 |
183 | (_, _), stats = _read_tile(input, tile_x, tile_y, tile_z, tilesize)
184 |
185 | click.echo(json.dumps(stats))
186 |
187 |
188 | @cli.command()
189 | @options.file_in_arg
190 | @click.option(
191 | "--reader",
192 | type=str,
193 | help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`",
194 | )
195 | @click.option(
196 | "--tms",
197 | help="Path to TileMatrixSet JSON file.",
198 | type=click.Path(),
199 | )
200 | @click.option(
201 | "--reader-params",
202 | "-p",
203 | "reader_params",
204 | metavar="NAME=VALUE",
205 | multiple=True,
206 | callback=options_to_dict,
207 | help="Reader Options.",
208 | )
209 | def get_zooms(input, reader, tms, reader_params):
210 | """Get Mercator Zoom levels."""
211 | tilematrixset = default_tms
212 | if tms:
213 | with open(tms, "r") as f:
214 | tilematrixset = morecantile.TileMatrixSet(**json.load(f))
215 |
216 | if reader:
217 | module, classname = reader.rsplit(".", 1)
218 | reader = getattr(importlib.import_module(module), classname) # noqa
219 | if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)):
220 | warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1)
221 |
222 | DstReader = reader or Reader
223 |
224 | with DstReader(input, tms=tilematrixset, **reader_params) as cog:
225 | click.echo(json.dumps({"minzoom": cog.minzoom, "maxzoom": cog.maxzoom}))
226 |
227 |
228 | @cli.command()
229 | @options.file_in_arg
230 | @click.option("--zoom", "-z", type=int)
231 | @click.option(
232 | "--reader",
233 | type=str,
234 | help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`",
235 | )
236 | @click.option(
237 | "--tms",
238 | help="Path to TileMatrixSet JSON file.",
239 | type=click.Path(),
240 | )
241 | @click.option(
242 | "--reader-params",
243 | "-p",
244 | "reader_params",
245 | metavar="NAME=VALUE",
246 | multiple=True,
247 | callback=options_to_dict,
248 | help="Reader Options.",
249 | )
250 | def random(input, zoom, reader, tms, reader_params):
251 | """Get random tile."""
252 | tilematrixset = default_tms
253 | if tms:
254 | with open(tms, "r") as f:
255 | tilematrixset = morecantile.TileMatrixSet(**json.load(f))
256 |
257 | if reader:
258 | module, classname = reader.rsplit(".", 1)
259 | reader = getattr(importlib.import_module(module), classname) # noqa
260 | if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)):
261 | warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1)
262 |
263 | DstReader = reader or Reader
264 |
265 | with DstReader(input, tms=tilematrixset, **reader_params) as cog:
266 | if zoom is None:
267 | zoom = randint(cog.minzoom, cog.maxzoom)
268 | w, s, e, n = cog.get_geographic_bounds(tilematrixset.rasterio_geographic_crs)
269 |
270 | # Truncate BBox to the TMS bounds
271 | w = max(tilematrixset.bbox.left, w)
272 | s = max(tilematrixset.bbox.bottom, s)
273 | e = min(tilematrixset.bbox.right, e)
274 | n = min(tilematrixset.bbox.top, n)
275 |
276 | ul_tile = tilematrixset.tile(w, n, zoom)
277 | lr_tile = tilematrixset.tile(e, s, zoom)
278 | extrema = {
279 | "x": {"min": ul_tile.x, "max": lr_tile.x + 1},
280 | "y": {"min": ul_tile.y, "max": lr_tile.y + 1},
281 | }
282 |
283 | x = sample(range(extrema["x"]["min"], extrema["x"]["max"]), 1)[0]
284 | y = sample(range(extrema["y"]["min"], extrema["y"]["max"]), 1)[0]
285 |
286 | click.echo(f"{zoom}-{x}-{y}")
287 |
288 |
289 | @cli.command()
290 | @click.argument("src_path", type=str, nargs=1, required=True)
291 | @click.option("--port", type=int, default=8080, help="Webserver port (default: 8080)")
292 | @click.option(
293 | "--host",
294 | type=str,
295 | default="127.0.0.1",
296 | help="Webserver host url (default: 127.0.0.1)",
297 | )
298 | @click.option(
299 | "--server-only",
300 | is_flag=True,
301 | default=False,
302 | help="Launch API without opening the rio-viz web-page.",
303 | )
304 | @click.option(
305 | "--reader",
306 | type=str,
307 | help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`",
308 | )
309 | @click.option(
310 | "--config",
311 | "config",
312 | metavar="NAME=VALUE",
313 | multiple=True,
314 | callback=options._cb_key_val,
315 | help="GDAL configuration options.",
316 | )
317 | @click.option(
318 | "--reader-params",
319 | "-p",
320 | "reader_params",
321 | metavar="NAME=VALUE",
322 | multiple=True,
323 | callback=options_to_dict,
324 | help="Reader Options.",
325 | )
326 | @click.option(
327 | "--io",
328 | "io_backend",
329 | type=click.Choice(["vsifile", "rasterio"], case_sensitive=True),
330 | help="IO Backend Options.",
331 | default="rasterio",
332 | )
333 | def viz(src_path, port, host, server_only, reader, config, reader_params, io_backend):
334 | """WEB UI to visualize VSI statistics for a web mercator tile requests."""
335 | if reader:
336 | module, classname = reader.rsplit(".", 1)
337 | reader = getattr(importlib.import_module(module), classname) # noqa
338 | if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)):
339 | warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1)
340 |
341 | DstReader = reader or Reader
342 |
343 | config = config or {}
344 |
345 | application = TileDebug(
346 | src_path=src_path,
347 | reader=DstReader,
348 | reader_params=reader_params,
349 | port=port,
350 | host=host,
351 | config=config,
352 | io_backend=io_backend,
353 | )
354 | if not server_only:
355 | click.echo(f"Viewer started at {application.template_url}", err=True)
356 | click.launch(application.template_url)
357 |
358 | application.start()
359 |
--------------------------------------------------------------------------------
/tilebench/static/spherical-mercator.js:
--------------------------------------------------------------------------------
1 | var SphericalMercator = (function(){
2 |
3 | // Closures including constants and other precalculated values.
4 | var cache = {},
5 | EPSLN = 1.0e-10,
6 | D2R = Math.PI / 180,
7 | R2D = 180 / Math.PI,
8 | // 900913 properties.
9 | A = 6378137.0,
10 | MAXEXTENT = 20037508.342789244;
11 |
12 |
13 | // SphericalMercator constructor: precaches calculations
14 | // for fast tile lookups.
15 | function SphericalMercator(options) {
16 | options = options || {};
17 | this.size = options.size || 256;
18 | if (!cache[this.size]) {
19 | var size = this.size;
20 | var c = cache[this.size] = {};
21 | c.Bc = [];
22 | c.Cc = [];
23 | c.zc = [];
24 | c.Ac = [];
25 | for (var d = 0; d < 30; d++) {
26 | c.Bc.push(size / 360);
27 | c.Cc.push(size / (2 * Math.PI));
28 | c.zc.push(size / 2);
29 | c.Ac.push(size);
30 | size *= 2;
31 | }
32 | }
33 | this.Bc = cache[this.size].Bc;
34 | this.Cc = cache[this.size].Cc;
35 | this.zc = cache[this.size].zc;
36 | this.Ac = cache[this.size].Ac;
37 | this.MAXEXTENT = MAXEXTENT;
38 | };
39 |
40 | // Convert lon lat to screen pixel value
41 | //
42 | // - `ll` {Array} `[lon, lat]` array of geographic coordinates.
43 | // - `zoom` {Number} zoom level.
44 | SphericalMercator.prototype.px = function(ll, zoom) {
45 | var d = this.zc[zoom];
46 | var f = Math.min(Math.max(Math.sin(D2R * ll[1]), -0.9999), 0.9999);
47 | var x = Math.round(d + ll[0] * this.Bc[zoom]);
48 | var y = Math.round(d + 0.5 * Math.log((1 + f) / (1 - f)) * (-this.Cc[zoom]));
49 | (x > this.Ac[zoom]) && (x = this.Ac[zoom]);
50 | (y > this.Ac[zoom]) && (y = this.Ac[zoom]);
51 | //(x < 0) && (x = 0);
52 | //(y < 0) && (y = 0);
53 | return [x, y];
54 | };
55 |
56 | // Convert screen pixel value to lon lat
57 | //
58 | // - `px` {Array} `[x, y]` array of geographic coordinates.
59 | // - `zoom` {Number} zoom level.
60 | SphericalMercator.prototype.ll = function(px, zoom) {
61 | var g = (px[1] - this.zc[zoom]) / (-this.Cc[zoom]);
62 | var lon = (px[0] - this.zc[zoom]) / this.Bc[zoom];
63 | var lat = R2D * (2 * Math.atan(Math.exp(g)) - 0.5 * Math.PI);
64 | return [lon, lat];
65 | };
66 |
67 | // Convert tile xyz value to bbox of the form `[w, s, e, n]`
68 | //
69 | // - `x` {Number} x (longitude) number.
70 | // - `y` {Number} y (latitude) number.
71 | // - `zoom` {Number} zoom.
72 | // - `tms_style` {Boolean} whether to compute using tms-style.
73 | // - `srs` {String} projection for resulting bbox (WGS84|900913).
74 | // - `return` {Array} bbox array of values in form `[w, s, e, n]`.
75 | SphericalMercator.prototype.bbox = function(x, y, zoom, tms_style, srs) {
76 | // Convert xyz into bbox with srs WGS84
77 | if (tms_style) {
78 | y = (Math.pow(2, zoom) - 1) - y;
79 | }
80 | // Use +y to make sure it's a number to avoid inadvertent concatenation.
81 | var ll = [x * this.size, (+y + 1) * this.size]; // lower left
82 | // Use +x to make sure it's a number to avoid inadvertent concatenation.
83 | var ur = [(+x + 1) * this.size, y * this.size]; // upper right
84 | var bbox = this.ll(ll, zoom).concat(this.ll(ur, zoom));
85 |
86 | // If web mercator requested reproject to 900913.
87 | if (srs === '900913') {
88 | return this.convert(bbox, '900913');
89 | } else {
90 | return bbox;
91 | }
92 | };
93 |
94 | // Convert bbox to xyx bounds
95 | //
96 | // - `bbox` {Number} bbox in the form `[w, s, e, n]`.
97 | // - `zoom` {Number} zoom.
98 | // - `tms_style` {Boolean} whether to compute using tms-style.
99 | // - `srs` {String} projection of input bbox (WGS84|900913).
100 | // - `@return` {Object} XYZ bounds containing minX, maxX, minY, maxY properties.
101 | SphericalMercator.prototype.xyz = function(bbox, zoom, tms_style, srs) {
102 | // If web mercator provided reproject to WGS84.
103 | if (srs === '900913') {
104 | bbox = this.convert(bbox, 'WGS84');
105 | }
106 |
107 | var ll = [bbox[0], bbox[1]]; // lower left
108 | var ur = [bbox[2], bbox[3]]; // upper right
109 | var px_ll = this.px(ll, zoom);
110 | var px_ur = this.px(ur, zoom);
111 | // Y = 0 for XYZ is the top hence minY uses px_ur[1].
112 | var bounds = {
113 | minX: Math.floor(px_ll[0] / this.size),
114 | minY: Math.floor(px_ur[1] / this.size),
115 | maxX: Math.floor((px_ur[0] - 1) / this.size),
116 | maxY: Math.floor((px_ll[1] - 1) / this.size)
117 | };
118 | if (tms_style) {
119 | var tms = {
120 | minY: (Math.pow(2, zoom) - 1) - bounds.maxY,
121 | maxY: (Math.pow(2, zoom) - 1) - bounds.minY
122 | };
123 | bounds.minY = tms.minY;
124 | bounds.maxY = tms.maxY;
125 | }
126 | return bounds;
127 | };
128 |
129 | // Convert projection of given bbox.
130 | //
131 | // - `bbox` {Number} bbox in the form `[w, s, e, n]`.
132 | // - `to` {String} projection of output bbox (WGS84|900913). Input bbox
133 | // assumed to be the "other" projection.
134 | // - `@return` {Object} bbox with reprojected coordinates.
135 | SphericalMercator.prototype.convert = function(bbox, to) {
136 | if (to === '900913') {
137 | return this.forward(bbox.slice(0, 2)).concat(this.forward(bbox.slice(2,4)));
138 | } else {
139 | return this.inverse(bbox.slice(0, 2)).concat(this.inverse(bbox.slice(2,4)));
140 | }
141 | };
142 |
143 | // Convert lon/lat values to 900913 x/y.
144 | SphericalMercator.prototype.forward = function(ll) {
145 | var xy = [
146 | A * ll[0] * D2R,
147 | A * Math.log(Math.tan((Math.PI*0.25) + (0.5 * ll[1] * D2R)))
148 | ];
149 | // if xy value is beyond maxextent (e.g. poles), return maxextent.
150 | (xy[0] > MAXEXTENT) && (xy[0] = MAXEXTENT);
151 | (xy[0] < -MAXEXTENT) && (xy[0] = -MAXEXTENT);
152 | (xy[1] > MAXEXTENT) && (xy[1] = MAXEXTENT);
153 | (xy[1] < -MAXEXTENT) && (xy[1] = -MAXEXTENT);
154 | return xy;
155 | };
156 |
157 | // Convert 900913 x/y values to lon/lat.
158 | SphericalMercator.prototype.inverse = function(xy) {
159 | return [
160 | (xy[0] * R2D / A),
161 | ((Math.PI*0.5) - 2.0 * Math.atan(Math.exp(-xy[1] / A))) * R2D
162 | ];
163 | };
164 |
165 | return SphericalMercator;
166 |
167 | })();
168 |
169 | if (typeof module !== 'undefined' && typeof exports !== 'undefined') {
170 | module.exports = exports = SphericalMercator;
171 | }
172 |
--------------------------------------------------------------------------------
/tilebench/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | TileBench
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
67 |
68 |
69 |
70 |
71 |
98 |
99 |
109 |
110 |
490 |
491 |
492 |
493 |
--------------------------------------------------------------------------------
/tilebench/viz.py:
--------------------------------------------------------------------------------
1 | """Tilebench."""
2 |
3 | import math
4 | import pathlib
5 | from typing import Dict, Optional, Tuple, Type
6 |
7 | import attr
8 | import morecantile
9 | import numpy
10 | import rasterio
11 | import uvicorn
12 | from fastapi import APIRouter, FastAPI, Path, Query
13 | from fastapi.staticfiles import StaticFiles
14 | from rasterio import windows
15 | from rasterio._path import _parse_path as parse_path
16 | from rasterio.crs import CRS
17 | from rasterio.warp import calculate_default_transform, transform_geom
18 | from rio_tiler.io import BaseReader, Reader
19 | from rio_tiler.utils import render
20 | from starlette.requests import Request
21 | from starlette.responses import HTMLResponse, Response
22 | from starlette.templating import Jinja2Templates
23 | from typing_extensions import Annotated
24 |
25 | from tilebench import Timer
26 | from tilebench import profile as profiler
27 | from tilebench.middleware import NoCacheMiddleware
28 | from tilebench.resources.responses import GeoJSONResponse, PNGResponse
29 |
30 | template_dir = str(pathlib.Path(__file__).parent.joinpath("templates"))
31 | static_dir = str(pathlib.Path(__file__).parent.joinpath("static"))
32 | templates = Jinja2Templates(directory=template_dir)
33 |
34 | tms = morecantile.tms.get("WebMercatorQuad")
35 | WGS84_CRS = CRS.from_epsg(4326)
36 |
37 |
38 | def bbox_to_feature(
39 | bbox: Tuple[float, float, float, float],
40 | properties: Optional[Dict] = None,
41 | ) -> Dict:
42 | """Create a GeoJSON feature polygon from a bounding box."""
43 | # Dateline crossing dataset
44 | if bbox[0] > bbox[2]:
45 | bounds_left = [-180, bbox[1], bbox[2], bbox[3]]
46 | bounds_right = [bbox[0], bbox[1], 180, bbox[3]]
47 |
48 | features = [
49 | {
50 | "geometry": {
51 | "type": "Polygon",
52 | "coordinates": [
53 | [
54 | [bounds_left[0], bounds_left[3]],
55 | [bounds_left[0], bounds_left[1]],
56 | [bounds_left[2], bounds_left[1]],
57 | [bounds_left[2], bounds_left[3]],
58 | [bounds_left[0], bounds_left[3]],
59 | ]
60 | ],
61 | },
62 | "properties": properties or {},
63 | "type": "Feature",
64 | },
65 | {
66 | "geometry": {
67 | "type": "Polygon",
68 | "coordinates": [
69 | [
70 | [bounds_right[0], bounds_right[3]],
71 | [bounds_right[0], bounds_right[1]],
72 | [bounds_right[2], bounds_right[1]],
73 | [bounds_right[2], bounds_right[3]],
74 | [bounds_right[0], bounds_right[3]],
75 | ]
76 | ],
77 | },
78 | "properties": properties or {},
79 | "type": "Feature",
80 | },
81 | ]
82 | else:
83 | features = [
84 | {
85 | "geometry": {
86 | "type": "Polygon",
87 | "coordinates": [
88 | [
89 | [bbox[0], bbox[3]],
90 | [bbox[0], bbox[1]],
91 | [bbox[2], bbox[1]],
92 | [bbox[2], bbox[3]],
93 | [bbox[0], bbox[3]],
94 | ]
95 | ],
96 | },
97 | "properties": properties or {},
98 | "type": "Feature",
99 | },
100 | ]
101 |
102 | return {"type": "FeatureCollection", "features": features}
103 |
104 |
105 | def dims(total: int, chop: int):
106 | """Given a total number of pixels, chop into equal chunks.
107 |
108 | yeilds (offset, size) tuples
109 | >>> list(dims(512, 256))
110 | [(0, 256), (256, 256)]
111 | >>> list(dims(502, 256))
112 | [(0, 256), (256, 246)]
113 | >>> list(dims(522, 256))
114 | [(0, 256), (256, 256), (512, 10)]
115 | """
116 | for a in range(int(math.ceil(total / chop))):
117 | offset = a * chop
118 | yield offset, chop
119 |
120 |
121 | @attr.s
122 | class TileDebug:
123 | """Creates a very minimal server using fastAPI + Uvicorn."""
124 |
125 | src_path: str = attr.ib()
126 | reader: Type[BaseReader] = attr.ib(default=Reader)
127 | reader_params: Dict = attr.ib(factory=dict)
128 |
129 | app: FastAPI = attr.ib(default=attr.Factory(FastAPI))
130 |
131 | port: int = attr.ib(default=8080)
132 | host: str = attr.ib(default="127.0.0.1")
133 | config: Dict = attr.ib(default=dict)
134 | io_backend: str = attr.ib(default="rasterio")
135 |
136 | router: Optional[APIRouter] = attr.ib(init=False)
137 |
138 | def __attrs_post_init__(self):
139 | """Update App."""
140 | # we force NO CACHE for our path
141 | self.config.update(
142 | {"CPL_VSIL_CURL_NON_CACHED": parse_path(self.src_path).as_vsi()}
143 | )
144 |
145 | self.router = APIRouter()
146 | self.register_routes()
147 | self.app.include_router(self.router)
148 | self.app.mount("/static", StaticFiles(directory=static_dir), name="static")
149 | self.app.add_middleware(NoCacheMiddleware)
150 |
151 | def register_routes(self):
152 | """Register routes to the FastAPI app."""
153 |
154 | @self.router.get(r"/tiles/{z}/{x}/{y}.png", response_class=PNGResponse)
155 | def image(
156 | response: Response,
157 | z: Annotated[
158 | int,
159 | Path(
160 | description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.",
161 | ),
162 | ],
163 | x: Annotated[
164 | int,
165 | Path(
166 | description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.",
167 | ),
168 | ],
169 | y: Annotated[
170 | int,
171 | Path(
172 | description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.",
173 | ),
174 | ],
175 | ):
176 | """Handle /image requests."""
177 | with self.reader(self.src_path, **self.reader_params) as src_dst:
178 | img = src_dst.tile(x, y, z)
179 |
180 | return PNGResponse(
181 | render(
182 | numpy.zeros((1, 256, 256), dtype="uint8"),
183 | img.mask,
184 | img_format="PNG",
185 | zlevel=6,
186 | )
187 | )
188 |
189 | @self.router.get(r"/tiles/{z}/{x}/{y}")
190 | def tile(
191 | response: Response,
192 | z: Annotated[
193 | int,
194 | Path(
195 | description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.",
196 | ),
197 | ],
198 | x: Annotated[
199 | int,
200 | Path(
201 | description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.",
202 | ),
203 | ],
204 | y: Annotated[
205 | int,
206 | Path(
207 | description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.",
208 | ),
209 | ],
210 | ):
211 | """Handle /tiles requests."""
212 |
213 | @profiler(
214 | kernels=False,
215 | quiet=True,
216 | add_to_return=True,
217 | raw=False,
218 | config=self.config,
219 | io=self.io_backend,
220 | )
221 | def _read_tile(src_path: str, x: int, y: int, z: int):
222 | with self.reader(src_path, **self.reader_params) as src_dst:
223 | return src_dst.tile(x, y, z)
224 |
225 | with Timer() as t:
226 | (_, _), stats = _read_tile(self.src_path, x, y, z)
227 |
228 | head_results = "head;count={count}".format(**stats["HEAD"])
229 | get_results = "get;count={count};size={bytes}".format(**stats["GET"])
230 | ranges_results = "ranges; values={}".format("|".join(stats["GET"]["ranges"]))
231 | response.headers["VSI-Stats"] = (
232 | f"{head_results}, {get_results}, {ranges_results}"
233 | )
234 |
235 | response.headers["server-timing"] = (
236 | f"dataread; dur={round(t.elapsed * 1000, 2)}"
237 | )
238 | return "OK"
239 |
240 | @self.router.get(
241 | r"/info.geojson",
242 | response_model_exclude_none=True,
243 | response_class=GeoJSONResponse,
244 | )
245 | def info():
246 | """Return a geojson."""
247 | with self.reader(self.src_path, **self.reader_params) as src_dst:
248 | bounds = src_dst.get_geographic_bounds(
249 | src_dst.tms.rasterio_geographic_crs
250 | )
251 |
252 | width, height = src_dst.width, src_dst.height
253 | if not all([width, height]):
254 | return bbox_to_feature(
255 | bounds,
256 | properties={
257 | "bounds": bounds,
258 | "crs": src_dst.crs.to_epsg(),
259 | "ifd": [],
260 | },
261 | )
262 |
263 | info = {
264 | "width": width,
265 | "height": height,
266 | "bounds": bounds,
267 | "crs": src_dst.crs.to_epsg(),
268 | }
269 |
270 | dst_affine, _, _ = calculate_default_transform(
271 | src_dst.crs,
272 | tms.crs,
273 | width,
274 | height,
275 | *src_dst.bounds,
276 | )
277 |
278 | # Raw resolution Zoom and IFD info
279 | resolution = max(abs(dst_affine[0]), abs(dst_affine[4]))
280 | zoom = tms.zoom_for_res(resolution)
281 | info["maxzoom"] = zoom
282 |
283 | try:
284 | blocksize = src_dst.dataset.block_shapes[0]
285 | except Exception:
286 | blocksize = src_dst.width
287 |
288 | ifd = [
289 | {
290 | "Level": 0,
291 | "Width": width,
292 | "Height": height,
293 | "Blocksize": blocksize,
294 | "Decimation": 0,
295 | "MercatorZoom": zoom,
296 | "MercatorResolution": resolution,
297 | }
298 | ]
299 |
300 | try:
301 | ovr = src_dst.dataset.overviews(1)
302 | except Exception:
303 | ovr = []
304 |
305 | info["overviews"] = len(ovr)
306 |
307 | # Overviews Zooms and IFD info
308 | for ix, decim in enumerate(ovr):
309 | with rasterio.open(self.src_path, OVERVIEW_LEVEL=ix) as ovr_dst:
310 | dst_affine, _, _ = calculate_default_transform(
311 | ovr_dst.crs,
312 | tms.crs,
313 | ovr_dst.width,
314 | ovr_dst.height,
315 | *ovr_dst.bounds,
316 | )
317 | resolution = max(abs(dst_affine[0]), abs(dst_affine[4]))
318 | zoom = tms.zoom_for_res(resolution)
319 |
320 | ifd.append(
321 | {
322 | "Level": ix + 1,
323 | "Width": ovr_dst.width,
324 | "Height": ovr_dst.height,
325 | "Blocksize": ovr_dst.block_shapes[0],
326 | "Decimation": decim,
327 | "MercatorZoom": zoom,
328 | "MercatorResolution": resolution,
329 | }
330 | )
331 |
332 | info["ifd"] = ifd
333 | info["minzoom"] = zoom # either the same has maxzoom or last IFD
334 |
335 | return bbox_to_feature(info["bounds"], properties=info)
336 |
337 | @self.router.get(
338 | r"/tiles.geojson",
339 | response_model_exclude_none=True,
340 | response_class=GeoJSONResponse,
341 | )
342 | def grid(ovr_level: Annotated[int, Query(description="Overview Level")]):
343 | """return geojson."""
344 | # Will only work with Rasterio compatible dataset
345 | try:
346 | options = {"OVERVIEW_LEVEL": ovr_level - 1} if ovr_level else {}
347 | with rasterio.open(self.src_path, **options) as src_dst:
348 | feats = []
349 | blockxsize, blockysize = src_dst.block_shapes[0]
350 | winds = (
351 | windows.Window(
352 | col_off=col_off, row_off=row_off, width=w, height=h
353 | )
354 | for row_off, h in dims(src_dst.height, blockysize)
355 | for col_off, w in dims(src_dst.width, blockxsize)
356 | )
357 | for window in winds:
358 | fc = bbox_to_feature(windows.bounds(window, src_dst.transform))
359 | for feat in fc.get("features", []):
360 | geom = transform_geom(
361 | src_dst.crs, WGS84_CRS, feat["geometry"]
362 | )
363 | feats.append(
364 | {
365 | "type": "Feature",
366 | "geometry": geom,
367 | "properties": {"window": str(window)},
368 | }
369 | )
370 |
371 | except Exception:
372 | feats = []
373 |
374 | return {"type": "FeatureCollection", "features": feats}
375 |
376 | @self.router.get(
377 | "/",
378 | responses={200: {"description": "Simple COG viewer."}},
379 | response_class=HTMLResponse,
380 | )
381 | async def viewer(request: Request):
382 | """Handle /index.html."""
383 | return templates.TemplateResponse(
384 | name="index.html",
385 | context={
386 | "request": request,
387 | "geojson_endpoint": str(request.url_for("info")),
388 | "grid_endpoint": str(request.url_for("grid")),
389 | "tile_endpoint": str(
390 | request.url_for("tile", z="${z}", x="${x}", y="${y}")
391 | ),
392 | "image_endpoint": str(
393 | request.url_for("image", z="{z}", x="{x}", y="{y}")
394 | ),
395 | },
396 | media_type="text/html",
397 | )
398 |
399 | @property
400 | def endpoint(self) -> str:
401 | """Get endpoint url."""
402 | return f"http://{self.host}:{self.port}"
403 |
404 | @property
405 | def template_url(self) -> str:
406 | """Get simple app template url."""
407 | return f"http://{self.host}:{self.port}"
408 |
409 | @property
410 | def docs_url(self) -> str:
411 | """Get simple app template url."""
412 | return f"http://{self.host}:{self.port}/docs"
413 |
414 | def start(self):
415 | """Start tile server."""
416 | uvicorn.run(app=self.app, host=self.host, port=self.port, log_level="info")
417 |
--------------------------------------------------------------------------------