├── .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 | tilebench 5 |

6 |

7 | Inspect HEAD/LIST/GET requests within Rasterio 8 |

9 |

10 | 11 | Test 12 | 13 | 14 | Coverage 15 | 16 | 17 | Package version 18 | 19 | 20 | Downloads 21 | 22 | 23 | Downloads 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 | ![](https://user-images.githubusercontent.com/10407788/103528918-17180880-4e85-11eb-91b3-d60659b15e80.png) 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 | ![](https://user-images.githubusercontent.com/10407788/103529132-65c5a280-4e85-11eb-96e2-f59e915c8ed8.png) 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 |
100 |
101 |
102 |
103 | 104 |
105 |
106 |
107 |
108 |
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 | --------------------------------------------------------------------------------