├── .github ├── codecov.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── Display_tiles_in_notebook.ipynb └── STAC_in_notebook.ipynb ├── pyproject.toml ├── rio_viz ├── __init__.py ├── app.py ├── io │ ├── __init__.py │ ├── mosaic.py │ └── reader.py ├── resources │ ├── __init__.py │ └── enums.py ├── scripts │ ├── __init__.py │ └── cli.py └── templates │ ├── assets.html │ ├── bands.html │ ├── index.html │ ├── map.html │ └── wmts.xml └── tests ├── fixtures ├── cog.tif ├── cogb1.tif ├── cogb2.tif ├── cogb3.tif ├── mosaic_cog1.tif ├── mosaic_cog2.tif └── noncog.tif ├── test_app.py ├── test_cli.py └── test_enums.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: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | env: 11 | LATEST_PY_VERSION: '3.12' 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: 19 | - '3.9' 20 | - '3.10' 21 | - '3.11' 22 | - '3.12' 23 | - '3.13' 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | python -m pip install .["mvt,test"] 36 | 37 | - name: Run tests 38 | run: python -m pytest --cov rio_viz --cov-report xml --cov-report term-missing 39 | 40 | - name: run pre-commit 41 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 42 | run: | 43 | python -m pip install pre-commit 44 | pre-commit run --all-files 45 | 46 | - name: Upload Results 47 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 48 | uses: codecov/codecov-action@v1 49 | with: 50 | file: ./coverage.xml 51 | flags: unittests 52 | name: ${{ matrix.python-version }} 53 | fail_ci_if_error: false 54 | 55 | publish: 56 | needs: [tests] 57 | runs-on: ubuntu-latest 58 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 59 | steps: 60 | - uses: actions/checkout@v4 61 | - name: Set up Python 62 | uses: actions/setup-python@v5 63 | with: 64 | python-version: ${{ env.LATEST_PY_VERSION }} 65 | 66 | - name: Install dependencies 67 | run: | 68 | python -m pip install --upgrade pip 69 | python -m pip install hatch 70 | python -m hatch build 71 | 72 | - name: Set tag version 73 | id: tag 74 | run: | 75 | echo "version=${GITHUB_REF#refs/*/}" 76 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 77 | 78 | - name: Set module version 79 | id: module 80 | run: | 81 | hatch --quiet version 82 | echo "version=$(hatch --quiet version)" >> $GITHUB_OUTPUT 83 | 84 | - name: Build and publish 85 | if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}} 86 | env: 87 | HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} 88 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} 89 | run: | 90 | python -m hatch publish 91 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | rio_viz/*.c -------------------------------------------------------------------------------- /.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.3.5 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.9.0 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.14.0 (2025-03-20) 2 | 3 | * add `--geojson` option to add a GeoJSON Feature or FeatureCollection on the map viewer 4 | 5 | # 0.13.1 (2025-03-19) 6 | 7 | * relaxe titiler requirement to `>=0.20,<0.22` 8 | 9 | # 0.13.0 (2025-01-20) 10 | 11 | * remove python 3.8 support 12 | * update titiler dependency to `>=0.20,<0.21` 13 | * remove docker image publishing 14 | 15 | # 0.12.2 (2024-04-24) 16 | 17 | * update titiler dependency to `>=0.16,<0.19` 18 | * add python 3.12 official support 19 | * change code formatter to `ruff-format` 20 | * switch to Hatch 21 | 22 | # 0.12.1 (2024-01-12) 23 | 24 | * fix invalid nodata overwriting 25 | 26 | # 0.12.0 (2024-01-12) 27 | 28 | * add Algorithm support and update basemap source 29 | * update titiler requirement to `>=0.16,<0.17` 30 | 31 | - renamed `/crop` endpoints to `/bbox/...` or `/feature/...` 32 | - `/crop/{minx},{miny},{maxx},{maxy}.{format}` -> `/bbox/{minx},{miny},{maxx},{maxy}.{format}` 33 | 34 | - `/crop/{minx},{miny},{maxx},{maxy}/{width}x{height}.{format}` -> `/bbox/{minx},{miny},{maxx},{maxy}/{width}x{height}.{format}` 35 | 36 | - `/crop [POST]` -> `/feature [POST]` 37 | 38 | - `/crop.{format} [POST]` -> `/feature.{format} [POST]` 39 | 40 | - `/crop/{width}x{height}.{format} [POST]` -> `/feature/{width}x{height}.{format} [POST]` 41 | 42 | 43 | # 0.11.0 (2023-07-27) 44 | 45 | * update titiler requirement to `>=0.13,<0.14` 46 | 47 | # 0.10.3 (2023-03-22) 48 | 49 | * switch `/map` viewer to maplibre 50 | 51 | ## 0.10.2 (2023-03-20) 52 | 53 | * handle dateline crossing dataset in html viewers 54 | * fix issue with FastAPI>0.93 55 | * update FastAPI dependency version 56 | 57 | ## 0.10.1 (2023-03-01) 58 | 59 | * update titiler requirement 60 | 61 | ## 0.10.0 (2022-12-16) 62 | 63 | * remove `AsyncBaseReader` support 64 | * add Jupyter Notebook compatible `Client` 65 | 66 | ```python 67 | import time 68 | 69 | import httpx 70 | from folium import Map, TileLayer 71 | 72 | from rio_viz.app import Client 73 | 74 | # Create rio-viz Client (using server-thread to launch background task) 75 | client = Client("https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif") 76 | 77 | # Gives some time for the server to setup 78 | time.sleep(1) 79 | 80 | r = httpx.get( 81 | f"{client.endpoint}/tilejson.json", 82 | params = { 83 | "rescale": "1600,2000", # from the info endpoint 84 | "colormap_name": "terrain", 85 | } 86 | ).json() 87 | 88 | bounds = r["bounds"] 89 | m = Map( 90 | location=((bounds[1] + bounds[3]) / 2,(bounds[0] + bounds[2]) / 2), 91 | zoom_start=r["minzoom"] 92 | ) 93 | 94 | aod_layer = TileLayer( 95 | tiles=r["tiles"][0], 96 | opacity=1, 97 | attr="Yo!!" 98 | ) 99 | aod_layer.add_to(m) 100 | m 101 | ``` 102 | 103 | * update `rio-tiler-mvt` requirement (**>=0.1,<0.2**) 104 | 105 | * add support for python 3.10 and 3.11 106 | * remove python 3.7 support 107 | * update titiler requirement to `0.10` 108 | * revert using static files for javascript libraries 109 | * add simple `/map` viewer 110 | 111 | ## 0.9.6 (2022-06-14) 112 | 113 | * update `titiler` and `starlette-cramjam` requirements 114 | 115 | ## 0.9.5 (2022-05-17) 116 | 117 | * Fix viewers. Statistics wasn't being displayed for raster with band names (instead of indexes number). 118 | 119 | ## 0.9.4 (2022-05-16) 120 | 121 | * fix issue for MultiBaseReader (`assets`) viewer 122 | * update titiler.core requirement to `>=0.5,<0.7` 123 | * add `/` as path for the viewer 124 | 125 | ## 0.9.3 (2022-04-01) 126 | 127 | * fix frontend click/point interaction for dataset crossing the antimeridian line 128 | 129 | ## 0.9.2 (2022-03-29) 130 | 131 | * better handle data with bbox crossing the antimeridian limit 132 | * include js and css code within the package to enable full offline mode 133 | * switch to pyproject.toml 134 | 135 | ## 0.9.1 (2022-02-25) 136 | 137 | * add `color-formula` in viz 138 | 139 | ## 0.9.0 (2022-02-25) 140 | 141 | * update titiler requirement and fix viewer bugs 142 | 143 | ## 0.8.0 (2021-11-30) 144 | 145 | * update to new titiler/rio-tiler/rio-cogeo version 146 | * remove Mapbox maps and switch to `stamen` basemap 147 | * remove python3.6 support 148 | 149 | ## 0.7.2 (2021-09-23) 150 | 151 | * use titiler custom JSONResponse to encode nan/inf/-inf values in response 152 | 153 | ## 0.7.1 (2021-09-17) 154 | 155 | * do not rescale data if there is a colormap 156 | 157 | ## 0.7.0 (2021-07-16) 158 | 159 | * add `titiler.core` as dependencies to reduce code duplication. 160 | * update code and templates to follow `titiler.core` specs. 161 | * add `/crop.{format}` POST endpoint to get image from polygon shaped GeoJSON (https://github.com/developmentseed/rio-viz/pull/36) 162 | * rename `/part` to `/crop` to match TiTiler (https://github.com/developmentseed/rio-viz/pull/36) 163 | * refactor dependencies to remove bidx in info/metadata/stats (https://github.com/developmentseed/rio-viz/pull/37) 164 | * refactor UI (https://github.com/developmentseed/rio-viz/pull/38) 165 | * add simple **MosaicReader** (https://github.com/developmentseed/rio-viz/pull/32) 166 | 167 | ```bash 168 | $ rio viz "tests/fixtures/mosaic_cog{1,2}.tif" --reader rio_viz.io.MosaicReader 169 | ``` 170 | 171 | ## 0.6.1 (2021-04-08) 172 | 173 | * update rio-tiler-mvt 174 | * use cache middleware to add `cache-control` headers. 175 | 176 | ## 0.6.0 (2021-03-23) 177 | 178 | * add dynamic dependency injection to better support multiple reader types (https://github.com/developmentseed/rio-viz/pull/28) 179 | * add better UI for MultiBaseReader (e.g STAC) 180 | * renamed `indexes` query parameter to `bidx` 181 | * update bands/assets/indexes query parameter style to follow the common specification 182 | 183 | ``` 184 | # before 185 | /tiles/9/150/189?indexes=1,2,3 186 | 187 | # now 188 | /tiles/9/150/189?bidx=1&bidx=2&bidx=3 189 | ``` 190 | 191 | ## 0.5.0 (2021-03-01) 192 | 193 | * renamed `rio_viz.ressources` to `rio_viz.resources` (https://github.com/developmentseed/titiler/pull/210) 194 | * update and reduce requirements 195 | * fix wrong tilesize setting in UI 196 | * update pre-commit configuration 197 | 198 | ## 0.4.4 (2021-01-27) 199 | 200 | * update requirements. 201 | * add Mapbox `dark` basemap as default. 202 | 203 | ## 0.4.3.post1 (2020-12-15) 204 | 205 | * add missing `__init__` in rio_viz.io submodule (https://github.com/developmentseed/rio-viz/pull/24) 206 | 207 | ## 0.4.3 (2020-12-15) 208 | 209 | * Fix error when `rio-tiler-mvt` is not installed (https://github.com/developmentseed/rio-viz/issues/21) 210 | 211 | ## 0.4.2 (2020-11-24) 212 | 213 | * update for rio-tiler 2.0.0rc3 214 | 215 | ## 0.4.1 (2020-11-17) 216 | 217 | * add `--server-only` options and add preview/part API. 218 | * add more output types. 219 | 220 | ## 0.4.0 (2020-11-09) 221 | 222 | **Refactor** 223 | 224 | * remove template factory 225 | * better FastAPI app definition (to be able to use it outside rio-viz) 226 | * remove `simple` template 227 | * use dataclasses 228 | * adapt for rio-tiler >= 2.0.0rc1 229 | * full async API 230 | * add `external` reader support 231 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues and pull requests are more than welcome. 4 | 5 | **dev install** 6 | 7 | ```bash 8 | $ git clone https://github.com/developmentseed/rio-viz.git 9 | $ cd rio-viz 10 | $ pip install -e .["test,dev"] 11 | ``` 12 | 13 | You can then run the tests with the following command: 14 | 15 | ```sh 16 | python -m pytest --cov rio_viz --cov-report term-missing 17 | ``` 18 | 19 | **pre-commit** 20 | 21 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. 22 | 23 | ```bash 24 | $ pre-commit install 25 | ``` 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | # rio-viz 2 | 3 |

4 | 5 |

A Rasterio plugin to visualize Cloud Optimized GeoTIFF in browser.

6 |

7 | 8 |

9 | 10 | Test 11 | 12 | 13 | Coverage 14 | 15 | 16 | Package version 17 | 18 | 19 | Downloads 20 | 21 |

22 | 23 | 24 | ## Install 25 | 26 | You can install rio-viz using pip 27 | 28 | ```bash 29 | $ pip install rio-viz 30 | ``` 31 | with 3d feature 32 | 33 | ```bash 34 | # 3d visualization features is optional 35 | $ pip install -U pip 36 | $ pip install rio-viz["mvt"] 37 | ``` 38 | 39 | Build from source 40 | 41 | ```bash 42 | $ git clone https://github.com/developmentseed/rio-viz.git 43 | $ cd rio-viz 44 | $ pip install -e . 45 | ``` 46 | 47 | ## CLI 48 | 49 | ```bash 50 | $ rio viz --help 51 | Usage: rio viz [OPTIONS] SRC_PATH 52 | 53 | Rasterio Viz cli. 54 | 55 | Options: 56 | --nodata NUMBER|nan Set nodata masking values for input dataset. 57 | --minzoom INTEGER Overwrite minzoom 58 | --maxzoom INTEGER Overwrite maxzoom 59 | --port INTEGER Webserver port (default: 8080) 60 | --host TEXT Webserver host url (default: 127.0.0.1) 61 | --no-check Ignore COG validation 62 | --reader TEXT rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader` 63 | --layers TEXT limit to specific layers (only used for MultiBand and MultiBase Readers). (e.g --layers b1 --layers b2). 64 | --server-only Launch API without opening the rio-viz web-page. 65 | --config NAME=VALUE GDAL configuration options. 66 | --help Show this message and exit. 67 | ``` 68 | 69 | ## Multi Reader support 70 | 71 | rio-viz support multiple/custom reader as long they are subclass of `rio_tiler.io.base.BaseReader`. 72 | 73 | ```bash 74 | # Multi Files as Bands 75 | $ rio viz "cog_band{2,3,4}.tif" --reader rio_viz.io.MultiFilesBandsReader 76 | 77 | # Simple Mosaic 78 | $ rio viz "tests/fixtures/mosaic_cog{1,2}.tif" --reader rio_viz.io.MosaicReader 79 | 80 | # MultiBandReader 81 | # Landsat 8 - rio-tiler-pds 82 | # We use `--layers` to limit the number of bands 83 | $ rio viz LC08_L1TP_013031_20130930_20170308_01_T1 \ 84 | --reader rio_tiler_pds.landsat.aws.landsat8.L8Reader \ 85 | --layers B1,B2 \ 86 | --config GDAL_DISABLE_READDIR_ON_OPEN=FALSE \ 87 | --config CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".TIF,.ovr" 88 | 89 | # MultiBaseReader 90 | # We use `--layers` to limit the number of assets 91 | rio viz https://earth-search.aws.element84.com/v0/collections/sentinel-s2-l2a-cogs/items/S2A_34SGA_20200318_0_L2A \ 92 | --reader rio_tiler.io.STACReader \ 93 | --layers B04,B03,B02 94 | ``` 95 | 96 | ## RestAPI 97 | 98 | When launching rio-viz, the application will create a FastAPI application to access and read the data you want. By default the CLI will open a web-page for you to explore your file but you can use `--server-only` option to ignore this. 99 | 100 | ```bash 101 | $ rio viz my.tif --server-only 102 | 103 | # In another console 104 | $ curl http://127.0.0.1:8080/info | jq 105 | { 106 | "bounds": [6.608576517072109, 51.270642883468895, 11.649386808679436, 53.89267160832534], 107 | "band_metadata": [...], 108 | "band_descriptions": [...], 109 | "dtype": "uint8", 110 | "nodata_type": "Mask", 111 | "colorinterp": [ 112 | "red", 113 | "green", 114 | "blue" 115 | ] 116 | } 117 | ``` 118 | 119 | You can see the full API documentation over `http://127.0.0.1:8080/docs` 120 | 121 | ![API documentation](https://user-images.githubusercontent.com/10407788/99135093-a7a53b80-25ee-11eb-98ba-0ce932775791.png) 122 | 123 | ## In Notebook environment 124 | 125 | Thanks to the awesome [server-thread](https://github.com/banesullivan/server-thread) we can use `rio-viz` application in Notebook environment. 126 | 127 | ```python 128 | import time 129 | 130 | import httpx 131 | from folium import Map, TileLayer 132 | 133 | from rio_viz.app import Client 134 | 135 | # Create rio-viz Client (using server-thread to launch backgroud task) 136 | client = Client("https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif") 137 | 138 | # Gives some time for the server to setup 139 | time.sleep(1) 140 | 141 | r = httpx.get( 142 | f"{client.endpoint}/tilejson.json", 143 | params = { 144 | "rescale": "1600,2000", # from the info endpoint 145 | "colormap_name": "terrain", 146 | } 147 | ).json() 148 | 149 | bounds = r["bounds"] 150 | m = Map( 151 | location=((bounds[1] + bounds[3]) / 2,(bounds[0] + bounds[2]) / 2), 152 | zoom_start=r["minzoom"] 153 | ) 154 | 155 | aod_layer = TileLayer( 156 | tiles=r["tiles"][0], 157 | opacity=1, 158 | attr="Yo!!" 159 | ) 160 | aod_layer.add_to(m) 161 | m 162 | ``` 163 | ![](https://user-images.githubusercontent.com/10407788/181458278-9ae197ae-5a30-469d-834f-36c6d8a57395.jpg) 164 | 165 | 166 | ## 3D (Experimental) 167 | 168 | rio-viz supports Mapbox VectorTiles encoding from a raster array. This feature was added to visualize sparse data stored as raster but will also work for dense array. This is highly experimental and might be slow to render in certain browser and/or for big rasters. 169 | 170 | ![](https://user-images.githubusercontent.com/10407788/56853984-4713b800-68fd-11e9-86a2-efbb041daeb0.gif) 171 | 172 | ## Contribution & Development 173 | 174 | See [CONTRIBUTING.md](https://github.com/developmentseed/rio-viz/blob/main/CONTRIBUTING.md) 175 | 176 | ## Authors 177 | 178 | Created by [Development Seed]() 179 | 180 | ## Changes 181 | 182 | See [CHANGES.md](https://github.com/developmentseed/rio-viz/blob/main/CHANGES.md). 183 | 184 | ## License 185 | 186 | See [LICENSE.txt](https://github.com/developmentseed/rio-viz/blob/main/LICENSE) 187 | -------------------------------------------------------------------------------- /examples/Display_tiles_in_notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "b6633f73", 6 | "metadata": {}, 7 | "source": [ 8 | "### Use rio-viz in Jupyter Notebook" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 5, 14 | "id": "55915667", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import time\n", 19 | "import httpx\n", 20 | "\n", 21 | "from folium import Map, TileLayer\n", 22 | "\n", 23 | "from rio_viz.app import Client" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 6, 29 | "id": "5c65b3d5", 30 | "metadata": {}, 31 | "outputs": [ 32 | { 33 | "name": "stdout", 34 | "output_type": "stream", 35 | "text": [ 36 | "Client is alive: True\n" 37 | ] 38 | } 39 | ], 40 | "source": [ 41 | "# Create rio-viz Client (using server-thread to launch backgroud task)\n", 42 | "client = Client(\"https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif\")\n", 43 | "\n", 44 | "# Gives some time for the server to setup\n", 45 | "time.sleep(1)\n", 46 | "\n", 47 | "# Check that client is running\n", 48 | "print(\"Client is alive: \", client.server.is_alive())" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 7, 54 | "id": "85e4c1e0", 55 | "metadata": { 56 | "scrolled": false 57 | }, 58 | "outputs": [ 59 | { 60 | "data": { 61 | "text/plain": [ 62 | "{'bounds': [2573000.0, 1085000.0, 2574000.0, 1086000.0],\n", 63 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/2056',\n", 64 | " 'band_metadata': [['b1',\n", 65 | " {'STATISTICS_COVARIANCES': '10685.98787505646',\n", 66 | " 'STATISTICS_EXCLUDEDVALUES': '-9999',\n", 67 | " 'STATISTICS_MAXIMUM': '2015.0944824219',\n", 68 | " 'STATISTICS_MEAN': '1754.471184271',\n", 69 | " 'STATISTICS_MINIMUM': '1615.8128662109',\n", 70 | " 'STATISTICS_SKIPFACTORX': '1',\n", 71 | " 'STATISTICS_SKIPFACTORY': '1',\n", 72 | " 'STATISTICS_STDDEV': '103.37305197708'}]],\n", 73 | " 'band_descriptions': [['b1', '']],\n", 74 | " 'dtype': 'float32',\n", 75 | " 'nodata_type': 'Nodata',\n", 76 | " 'colorinterp': ['gray'],\n", 77 | " 'scales': [1.0],\n", 78 | " 'offsets': [0.0],\n", 79 | " 'driver': 'GTiff',\n", 80 | " 'count': 1,\n", 81 | " 'width': 2000,\n", 82 | " 'height': 2000,\n", 83 | " 'overviews': [2, 4, 8],\n", 84 | " 'nodata_value': -9999.0}" 85 | ] 86 | }, 87 | "execution_count": 7, 88 | "metadata": {}, 89 | "output_type": "execute_result" 90 | } 91 | ], 92 | "source": [ 93 | "# Fetch info\n", 94 | "httpx.get(f\"{client.endpoint}/info\").json()" 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": 8, 100 | "id": "4cc8c900", 101 | "metadata": { 102 | "scrolled": false 103 | }, 104 | "outputs": [ 105 | { 106 | "data": { 107 | "text/html": [ 108 | "
Make this Notebook Trusted to load map: File -> Trust Notebook
" 214 | ], 215 | "text/plain": [ 216 | "" 217 | ] 218 | }, 219 | "execution_count": 8, 220 | "metadata": {}, 221 | "output_type": "execute_result" 222 | } 223 | ], 224 | "source": [ 225 | "r = httpx.get(\n", 226 | " f\"{client.endpoint}/tilejson.json\",\n", 227 | " params = {\n", 228 | " \"rescale\": \"1600,2000\", # from the info endpoint\n", 229 | " \"colormap_name\": \"terrain\",\n", 230 | " }\n", 231 | ").json()\n", 232 | "\n", 233 | "bounds = r[\"bounds\"]\n", 234 | "m = Map(\n", 235 | " location=((bounds[1] + bounds[3]) / 2,(bounds[0] + bounds[2]) / 2),\n", 236 | " zoom_start=r[\"minzoom\"]\n", 237 | ")\n", 238 | "\n", 239 | "aod_layer = TileLayer(\n", 240 | " tiles=r[\"tiles\"][0],\n", 241 | " opacity=1,\n", 242 | " attr=\"Yo!!\"\n", 243 | ")\n", 244 | "aod_layer.add_to(m)\n", 245 | "m" 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": 9, 251 | "id": "54d674e9", 252 | "metadata": {}, 253 | "outputs": [], 254 | "source": [ 255 | "client.shutdown()" 256 | ] 257 | }, 258 | { 259 | "cell_type": "code", 260 | "execution_count": null, 261 | "id": "adc13756", 262 | "metadata": {}, 263 | "outputs": [], 264 | "source": [] 265 | } 266 | ], 267 | "metadata": { 268 | "kernelspec": { 269 | "display_name": "Python 3 (ipykernel)", 270 | "language": "python", 271 | "name": "python3" 272 | }, 273 | "language_info": { 274 | "codemirror_mode": { 275 | "name": "ipython", 276 | "version": 3 277 | }, 278 | "file_extension": ".py", 279 | "mimetype": "text/x-python", 280 | "name": "python", 281 | "nbconvert_exporter": "python", 282 | "pygments_lexer": "ipython3", 283 | "version": "3.12.3" 284 | }, 285 | "vscode": { 286 | "interpreter": { 287 | "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" 288 | } 289 | } 290 | }, 291 | "nbformat": 4, 292 | "nbformat_minor": 5 293 | } 294 | -------------------------------------------------------------------------------- /examples/STAC_in_notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "b6633f73", 6 | "metadata": {}, 7 | "source": [ 8 | "### Use rio-viz in Jupyter Notebook" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 9, 14 | "id": "55915667", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import time\n", 19 | "import httpx\n", 20 | "\n", 21 | "from ipyleaflet import Map, ScaleControl, FullScreenControl, SplitMapControl, TileLayer\n", 22 | "\n", 23 | "from rio_tiler.io import STACReader\n", 24 | "from rio_viz.app import Client" 25 | ] 26 | }, 27 | { 28 | "cell_type": "code", 29 | "execution_count": 10, 30 | "id": "5c65b3d5", 31 | "metadata": {}, 32 | "outputs": [ 33 | { 34 | "name": "stdout", 35 | "output_type": "stream", 36 | "text": [ 37 | "Client is alive: True\n" 38 | ] 39 | } 40 | ], 41 | "source": [ 42 | "# Create rio-viz Client (using server-thread to launch backgroud task)\n", 43 | "client = Client(\n", 44 | " \"https://earth-search.aws.element84.com/v0/collections/sentinel-s2-l2a-cogs/items/S2A_34SGA_20200318_0_L2A\",\n", 45 | " reader=STACReader,\n", 46 | " # By default STACReader min/max zoom is 0->24\n", 47 | " # Knowledge of the Sentinel-2 data tell us it's more 8->14\n", 48 | " minzoom=8,\n", 49 | " maxzoom=14,\n", 50 | ")\n", 51 | "\n", 52 | "# Gives some time for the server to setup\n", 53 | "time.sleep(1)\n", 54 | "\n", 55 | "# Check that client is running\n", 56 | "print(\"Client is alive: \", client.server.is_alive())" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 11, 62 | "id": "21137570", 63 | "metadata": {}, 64 | "outputs": [ 65 | { 66 | "data": { 67 | "text/plain": [ 68 | "{'overview': {'bounds': [699960.0, 3490240.0, 809720.0, 3600000.0],\n", 69 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 70 | " 'band_metadata': [['b1', {}], ['b2', {}], ['b3', {}]],\n", 71 | " 'band_descriptions': [['b1', ''], ['b2', ''], ['b3', '']],\n", 72 | " 'dtype': 'uint8',\n", 73 | " 'nodata_type': 'Nodata',\n", 74 | " 'colorinterp': ['red', 'green', 'blue'],\n", 75 | " 'scales': [1.0, 1.0, 1.0],\n", 76 | " 'offsets': [0.0, 0.0, 0.0],\n", 77 | " 'driver': 'GTiff',\n", 78 | " 'count': 3,\n", 79 | " 'width': 343,\n", 80 | " 'height': 343,\n", 81 | " 'overviews': [2, 4],\n", 82 | " 'nodata_value': 0.0},\n", 83 | " 'B11': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 84 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 85 | " 'band_metadata': [['b1', {}]],\n", 86 | " 'band_descriptions': [['b1', '']],\n", 87 | " 'dtype': 'uint16',\n", 88 | " 'nodata_type': 'Nodata',\n", 89 | " 'colorinterp': ['gray'],\n", 90 | " 'scales': [1.0],\n", 91 | " 'offsets': [0.0],\n", 92 | " 'driver': 'GTiff',\n", 93 | " 'count': 1,\n", 94 | " 'width': 5490,\n", 95 | " 'height': 5490,\n", 96 | " 'overviews': [2, 4, 8],\n", 97 | " 'nodata_value': 0.0},\n", 98 | " 'B01': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 99 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 100 | " 'band_metadata': [['b1', {}]],\n", 101 | " 'band_descriptions': [['b1', '']],\n", 102 | " 'dtype': 'uint16',\n", 103 | " 'nodata_type': 'Nodata',\n", 104 | " 'colorinterp': ['gray'],\n", 105 | " 'scales': [1.0],\n", 106 | " 'offsets': [0.0],\n", 107 | " 'driver': 'GTiff',\n", 108 | " 'count': 1,\n", 109 | " 'width': 1830,\n", 110 | " 'height': 1830,\n", 111 | " 'overviews': [2, 4, 8],\n", 112 | " 'nodata_value': 0.0},\n", 113 | " 'B12': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 114 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 115 | " 'band_metadata': [['b1', {}]],\n", 116 | " 'band_descriptions': [['b1', '']],\n", 117 | " 'dtype': 'uint16',\n", 118 | " 'nodata_type': 'Nodata',\n", 119 | " 'colorinterp': ['gray'],\n", 120 | " 'scales': [1.0],\n", 121 | " 'offsets': [0.0],\n", 122 | " 'driver': 'GTiff',\n", 123 | " 'count': 1,\n", 124 | " 'width': 5490,\n", 125 | " 'height': 5490,\n", 126 | " 'overviews': [2, 4, 8, 16],\n", 127 | " 'nodata_value': 0.0},\n", 128 | " 'B02': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 129 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 130 | " 'band_metadata': [['b1', {}]],\n", 131 | " 'band_descriptions': [['b1', '']],\n", 132 | " 'dtype': 'uint16',\n", 133 | " 'nodata_type': 'Nodata',\n", 134 | " 'colorinterp': ['gray'],\n", 135 | " 'scales': [1.0],\n", 136 | " 'offsets': [0.0],\n", 137 | " 'driver': 'GTiff',\n", 138 | " 'count': 1,\n", 139 | " 'width': 10980,\n", 140 | " 'height': 10980,\n", 141 | " 'overviews': [2, 4, 8, 16],\n", 142 | " 'nodata_value': 0.0},\n", 143 | " 'B03': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 144 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 145 | " 'band_metadata': [['b1', {}]],\n", 146 | " 'band_descriptions': [['b1', '']],\n", 147 | " 'dtype': 'uint16',\n", 148 | " 'nodata_type': 'Nodata',\n", 149 | " 'colorinterp': ['gray'],\n", 150 | " 'scales': [1.0],\n", 151 | " 'offsets': [0.0],\n", 152 | " 'driver': 'GTiff',\n", 153 | " 'count': 1,\n", 154 | " 'width': 10980,\n", 155 | " 'height': 10980,\n", 156 | " 'overviews': [2, 4, 8, 16],\n", 157 | " 'nodata_value': 0.0},\n", 158 | " 'B04': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 159 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 160 | " 'band_metadata': [['b1', {}]],\n", 161 | " 'band_descriptions': [['b1', '']],\n", 162 | " 'dtype': 'uint16',\n", 163 | " 'nodata_type': 'Nodata',\n", 164 | " 'colorinterp': ['gray'],\n", 165 | " 'scales': [1.0],\n", 166 | " 'offsets': [0.0],\n", 167 | " 'driver': 'GTiff',\n", 168 | " 'count': 1,\n", 169 | " 'width': 10980,\n", 170 | " 'height': 10980,\n", 171 | " 'overviews': [2, 4, 8, 16],\n", 172 | " 'nodata_value': 0.0},\n", 173 | " 'AOT': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 174 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 175 | " 'band_metadata': [['b1', {}]],\n", 176 | " 'band_descriptions': [['b1', '']],\n", 177 | " 'dtype': 'uint16',\n", 178 | " 'nodata_type': 'Nodata',\n", 179 | " 'colorinterp': ['gray'],\n", 180 | " 'scales': [1.0],\n", 181 | " 'offsets': [0.0],\n", 182 | " 'driver': 'GTiff',\n", 183 | " 'count': 1,\n", 184 | " 'width': 1830,\n", 185 | " 'height': 1830,\n", 186 | " 'overviews': [2, 4, 8],\n", 187 | " 'nodata_value': 0.0},\n", 188 | " 'B05': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 189 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 190 | " 'band_metadata': [['b1', {}]],\n", 191 | " 'band_descriptions': [['b1', '']],\n", 192 | " 'dtype': 'uint16',\n", 193 | " 'nodata_type': 'Nodata',\n", 194 | " 'colorinterp': ['gray'],\n", 195 | " 'scales': [1.0],\n", 196 | " 'offsets': [0.0],\n", 197 | " 'driver': 'GTiff',\n", 198 | " 'count': 1,\n", 199 | " 'width': 5490,\n", 200 | " 'height': 5490,\n", 201 | " 'overviews': [2, 4, 8, 16],\n", 202 | " 'nodata_value': 0.0},\n", 203 | " 'B06': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 204 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 205 | " 'band_metadata': [['b1', {}]],\n", 206 | " 'band_descriptions': [['b1', '']],\n", 207 | " 'dtype': 'uint16',\n", 208 | " 'nodata_type': 'Nodata',\n", 209 | " 'colorinterp': ['gray'],\n", 210 | " 'scales': [1.0],\n", 211 | " 'offsets': [0.0],\n", 212 | " 'driver': 'GTiff',\n", 213 | " 'count': 1,\n", 214 | " 'width': 5490,\n", 215 | " 'height': 5490,\n", 216 | " 'overviews': [2, 4, 8, 16],\n", 217 | " 'nodata_value': 0.0},\n", 218 | " 'B07': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 219 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 220 | " 'band_metadata': [['b1', {}]],\n", 221 | " 'band_descriptions': [['b1', '']],\n", 222 | " 'dtype': 'uint16',\n", 223 | " 'nodata_type': 'Nodata',\n", 224 | " 'colorinterp': ['gray'],\n", 225 | " 'scales': [1.0],\n", 226 | " 'offsets': [0.0],\n", 227 | " 'driver': 'GTiff',\n", 228 | " 'count': 1,\n", 229 | " 'width': 5490,\n", 230 | " 'height': 5490,\n", 231 | " 'overviews': [2, 4, 8, 16],\n", 232 | " 'nodata_value': 0.0},\n", 233 | " 'B08': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 234 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 235 | " 'band_metadata': [['b1', {}]],\n", 236 | " 'band_descriptions': [['b1', '']],\n", 237 | " 'dtype': 'uint16',\n", 238 | " 'nodata_type': 'Nodata',\n", 239 | " 'colorinterp': ['gray'],\n", 240 | " 'scales': [1.0],\n", 241 | " 'offsets': [0.0],\n", 242 | " 'driver': 'GTiff',\n", 243 | " 'count': 1,\n", 244 | " 'width': 10980,\n", 245 | " 'height': 10980,\n", 246 | " 'overviews': [2, 4, 8, 16],\n", 247 | " 'nodata_value': 0.0},\n", 248 | " 'B8A': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 249 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 250 | " 'band_metadata': [['b1', {}]],\n", 251 | " 'band_descriptions': [['b1', '']],\n", 252 | " 'dtype': 'uint16',\n", 253 | " 'nodata_type': 'Nodata',\n", 254 | " 'colorinterp': ['gray'],\n", 255 | " 'scales': [1.0],\n", 256 | " 'offsets': [0.0],\n", 257 | " 'driver': 'GTiff',\n", 258 | " 'count': 1,\n", 259 | " 'width': 5490,\n", 260 | " 'height': 5490,\n", 261 | " 'overviews': [2, 4, 8, 16],\n", 262 | " 'nodata_value': 0.0},\n", 263 | " 'B09': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 264 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 265 | " 'band_metadata': [['b1', {}]],\n", 266 | " 'band_descriptions': [['b1', '']],\n", 267 | " 'dtype': 'uint16',\n", 268 | " 'nodata_type': 'Nodata',\n", 269 | " 'colorinterp': ['gray'],\n", 270 | " 'scales': [1.0],\n", 271 | " 'offsets': [0.0],\n", 272 | " 'driver': 'GTiff',\n", 273 | " 'count': 1,\n", 274 | " 'width': 1830,\n", 275 | " 'height': 1830,\n", 276 | " 'overviews': [2, 4, 8],\n", 277 | " 'nodata_value': 0.0},\n", 278 | " 'WVP': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 279 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 280 | " 'band_metadata': [['b1', {}]],\n", 281 | " 'band_descriptions': [['b1', '']],\n", 282 | " 'dtype': 'uint16',\n", 283 | " 'nodata_type': 'Nodata',\n", 284 | " 'colorinterp': ['gray'],\n", 285 | " 'scales': [1.0],\n", 286 | " 'offsets': [0.0],\n", 287 | " 'driver': 'GTiff',\n", 288 | " 'count': 1,\n", 289 | " 'width': 10980,\n", 290 | " 'height': 10980,\n", 291 | " 'overviews': [2, 4, 8, 16],\n", 292 | " 'nodata_value': 0.0},\n", 293 | " 'visual': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 294 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 295 | " 'band_metadata': [['b1', {}], ['b2', {}], ['b3', {}]],\n", 296 | " 'band_descriptions': [['b1', ''], ['b2', ''], ['b3', '']],\n", 297 | " 'dtype': 'uint8',\n", 298 | " 'nodata_type': 'Nodata',\n", 299 | " 'colorinterp': ['red', 'green', 'blue'],\n", 300 | " 'scales': [1.0, 1.0, 1.0],\n", 301 | " 'offsets': [0.0, 0.0, 0.0],\n", 302 | " 'driver': 'GTiff',\n", 303 | " 'count': 3,\n", 304 | " 'width': 10980,\n", 305 | " 'height': 10980,\n", 306 | " 'overviews': [2, 4, 8, 16],\n", 307 | " 'nodata_value': 0.0},\n", 308 | " 'SCL': {'bounds': [699960.0, 3490200.0, 809760.0, 3600000.0],\n", 309 | " 'crs': 'http://www.opengis.net/def/crs/EPSG/0/32634',\n", 310 | " 'band_metadata': [['b1', {}]],\n", 311 | " 'band_descriptions': [['b1', '']],\n", 312 | " 'dtype': 'uint8',\n", 313 | " 'nodata_type': 'Nodata',\n", 314 | " 'colorinterp': ['gray'],\n", 315 | " 'scales': [1.0],\n", 316 | " 'offsets': [0.0],\n", 317 | " 'driver': 'GTiff',\n", 318 | " 'count': 1,\n", 319 | " 'width': 5490,\n", 320 | " 'height': 5490,\n", 321 | " 'overviews': [2, 4, 8, 16],\n", 322 | " 'nodata_value': 0.0}}" 323 | ] 324 | }, 325 | "execution_count": 11, 326 | "metadata": {}, 327 | "output_type": "execute_result" 328 | } 329 | ], 330 | "source": [ 331 | "# Fetch info\n", 332 | "httpx.get(f\"{client.endpoint}/info\").json()" 333 | ] 334 | }, 335 | { 336 | "cell_type": "code", 337 | "execution_count": 14, 338 | "id": "4070b77d", 339 | "metadata": { 340 | "scrolled": false 341 | }, 342 | "outputs": [ 343 | { 344 | "data": { 345 | "application/vnd.jupyter.widget-view+json": { 346 | "model_id": "17bef26f130a48c1ab65e1cc9d4adcfa", 347 | "version_major": 2, 348 | "version_minor": 0 349 | }, 350 | "text/plain": [ 351 | "Map(center=[32.00833055925221, 23.794854319372455], controls=(ZoomControl(options=['position', 'zoom_in_text',…" 352 | ] 353 | }, 354 | "execution_count": 14, 355 | "metadata": {}, 356 | "output_type": "execute_result" 357 | } 358 | ], 359 | "source": [ 360 | "tilejson = httpx.get(\n", 361 | " f\"{client.endpoint}/tilejson.json\",\n", 362 | " params = {\n", 363 | " \"assets\": [\"B04\", \"B03\", \"B02\"],\n", 364 | " \"rescale\": \"0,10000\"\n", 365 | " }\n", 366 | ").json()\n", 367 | "\n", 368 | "bounds = tilejson[\"bounds\"]\n", 369 | "\n", 370 | "layer = TileLayer(\n", 371 | " url=tilejson[\"tiles\"][0],\n", 372 | " min_zoom=tilejson[\"minzoom\"],\n", 373 | " max_zoom=tilejson[\"maxzoom\"],\n", 374 | " bounds=((bounds[0],bounds[2]), (bounds[1], bounds[3])),\n", 375 | ")\n", 376 | "\n", 377 | "\n", 378 | "# Make the ipyleaflet map\n", 379 | "m = Map(center=((bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2), zoom=client.minzoom)\n", 380 | "m.add_layer(layer)\n", 381 | "m" 382 | ] 383 | }, 384 | { 385 | "cell_type": "code", 386 | "execution_count": 13, 387 | "id": "85e4c1e0", 388 | "metadata": { 389 | "scrolled": false 390 | }, 391 | "outputs": [ 392 | { 393 | "data": { 394 | "application/vnd.jupyter.widget-view+json": { 395 | "model_id": "8e49ca8686664e7aae06f10173013f7a", 396 | "version_major": 2, 397 | "version_minor": 0 398 | }, 399 | "text/plain": [ 400 | "Map(center=[32.00833055925221, 23.794854319372455], controls=(ZoomControl(options=['position', 'zoom_in_text',…" 401 | ] 402 | }, 403 | "execution_count": 13, 404 | "metadata": {}, 405 | "output_type": "execute_result" 406 | } 407 | ], 408 | "source": [ 409 | "left_tilejson = httpx.get(\n", 410 | " f\"{client.endpoint}/tilejson.json\",\n", 411 | " params = {\n", 412 | " \"assets\": [\"B04\", \"B03\", \"B02\"],\n", 413 | " \"rescale\": \"0,10000\"\n", 414 | " }\n", 415 | ").json()\n", 416 | "\n", 417 | "right_tilejson = httpx.get(\n", 418 | " f\"{client.endpoint}/tilejson.json\",\n", 419 | " params = {\n", 420 | " \"assets\": [\"B05\", \"B04\", \"B03\"],\n", 421 | " \"rescale\": \"0,10000\"\n", 422 | " }\n", 423 | ").json()\n", 424 | "\n", 425 | "bounds = tilejson[\"bounds\"]\n", 426 | "\n", 427 | "left = TileLayer(\n", 428 | " url=left_tilejson[\"tiles\"][0],\n", 429 | " min_zoom=left_tilejson[\"minzoom\"],\n", 430 | " max_zoom=left_tilejson[\"maxzoom\"],\n", 431 | " bounds=((bounds[0],bounds[2]), (bounds[1], bounds[3])),\n", 432 | ")\n", 433 | "\n", 434 | "right = TileLayer(\n", 435 | " url=right_tilejson[\"tiles\"][0],\n", 436 | " min_zoom=right_tilejson[\"minzoom\"],\n", 437 | " max_zoom=right_tilejson[\"maxzoom\"],\n", 438 | " bounds=((bounds[0],bounds[2]), (bounds[1], bounds[3])),\n", 439 | ")\n", 440 | "\n", 441 | "# Make the ipyleaflet map\n", 442 | "m = Map(center=((bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2), zoom=client.minzoom)\n", 443 | "control = SplitMapControl(left_layer=left, right_layer=right)\n", 444 | "\n", 445 | "m.add_control(control)\n", 446 | "m.add_control(ScaleControl(position='bottomleft'))\n", 447 | "m.add_control(FullScreenControl())\n", 448 | "m" 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": null, 454 | "id": "2069c2f2", 455 | "metadata": {}, 456 | "outputs": [], 457 | "source": [ 458 | "client.shutdown()" 459 | ] 460 | } 461 | ], 462 | "metadata": { 463 | "kernelspec": { 464 | "display_name": "Python 3 (ipykernel)", 465 | "language": "python", 466 | "name": "python3" 467 | }, 468 | "language_info": { 469 | "codemirror_mode": { 470 | "name": "ipython", 471 | "version": 3 472 | }, 473 | "file_extension": ".py", 474 | "mimetype": "text/x-python", 475 | "name": "python", 476 | "nbconvert_exporter": "python", 477 | "pygments_lexer": "ipython3", 478 | "version": "3.12.3" 479 | }, 480 | "vscode": { 481 | "interpreter": { 482 | "hash": "b0fa6594d8f4cbf19f97940f81e996739fb7646882a419484c72d19e05852a7e" 483 | } 484 | } 485 | }, 486 | "nbformat": 4, 487 | "nbformat_minor": 5 488 | } 489 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "rio-viz" 3 | description = "Visualize Cloud Optimized GeoTIFF in browser" 4 | readme = "README.md" 5 | requires-python = ">=3.9" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, 9 | ] 10 | classifiers = [ 11 | "Intended Audience :: Information Technology", 12 | "Intended Audience :: Science/Research", 13 | "License :: OSI Approved :: BSD License", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Topic :: Scientific/Engineering :: GIS", 20 | ] 21 | dynamic = ["version"] 22 | dependencies = [ 23 | "braceexpand", 24 | "rio-cogeo>=5.0", 25 | "titiler.core>=0.20.0,<0.22", 26 | "starlette-cramjam>=0.4,<0.5", 27 | "uvicorn", 28 | "server-thread>=0.2.0", 29 | ] 30 | 31 | [project.optional-dependencies] 32 | mvt = [ 33 | "rio-tiler-mvt>=0.2,<0.3", 34 | ] 35 | test = [ 36 | "pytest", 37 | "pytest-cov", 38 | "pytest-asyncio", 39 | "requests", 40 | ] 41 | dev = [ 42 | "pre-commit", 43 | ] 44 | 45 | [project.urls] 46 | Source = "https://github.com/developmentseed/rio-viz" 47 | 48 | [project.entry-points."rasterio.rio_plugins"] 49 | viz = "rio_viz.scripts.cli:viz" 50 | 51 | [build-system] 52 | requires = ["hatchling"] 53 | build-backend = "hatchling.build" 54 | 55 | [tool.hatch.version] 56 | path = "rio_viz/__init__.py" 57 | 58 | [tool.hatch.build.targets.sdist] 59 | exclude = [ 60 | "/tests", 61 | ".ruff_cache/", 62 | "examples/", 63 | ".github", 64 | ".history", 65 | ".bumpversion.cfg", 66 | ".gitignore", 67 | "Dockerfile", 68 | ".pre-commit-config.yaml", 69 | "CHANGES.md", 70 | "CONTRIBUTING.md", 71 | ] 72 | 73 | [tool.isort] 74 | profile = "black" 75 | known_first_party = ["rio_viz"] 76 | known_third_party = ["rasterio", "rio_tiler", "morecantile", "titiler"] 77 | forced_separate = ["titiler"] 78 | default_section = "THIRDPARTY" 79 | 80 | [tool.mypy] 81 | no_strict_optional = true 82 | 83 | [tool.ruff] 84 | line-length = 90 85 | 86 | [tool.ruff.lint] 87 | select = [ 88 | "D1", # pydocstyle errors 89 | "E", # pycodestyle errors 90 | "W", # pycodestyle warnings 91 | "F", # flake8 92 | "C", # flake8-comprehensions 93 | "B", # flake8-bugbear 94 | ] 95 | ignore = [ 96 | "E501", # line too long, handled by black 97 | "B008", # do not perform function calls in argument defaults 98 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 99 | "B028", 100 | ] 101 | 102 | [tool.ruff.lint.mccabe] 103 | max-complexity = 12 104 | 105 | [tool.bumpversion] 106 | current_version = "0.14.0" 107 | search = "{current_version}" 108 | replace = "{new_version}" 109 | regex = false 110 | tag = true 111 | commit = true 112 | tag_name = "{new_version}" 113 | 114 | [[tool.bumpversion.files]] 115 | filename = "rio_viz/__init__.py" 116 | search = '__version__ = "{current_version}"' 117 | replace = '__version__ = "{new_version}"' 118 | -------------------------------------------------------------------------------- /rio_viz/__init__.py: -------------------------------------------------------------------------------- 1 | """rio_viz.""" 2 | 3 | __version__ = "0.14.0" 4 | 5 | from rio_viz.app import viz # noqa 6 | -------------------------------------------------------------------------------- /rio_viz/app.py: -------------------------------------------------------------------------------- 1 | """rio_viz app.""" 2 | 3 | import urllib.parse 4 | from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union 5 | 6 | import attr 7 | import jinja2 8 | import rasterio 9 | import uvicorn 10 | from fastapi import APIRouter, Depends, FastAPI, HTTPException, Path, Query 11 | from geojson_pydantic.features import Feature 12 | from geojson_pydantic.geometries import MultiPolygon, Polygon 13 | from rio_tiler.constants import WGS84_CRS 14 | from rio_tiler.io import BaseReader, MultiBandReader, MultiBaseReader, Reader 15 | from rio_tiler.models import BandStatistics, Info 16 | from server_thread import ServerManager, ServerThread 17 | from starlette.middleware.cors import CORSMiddleware 18 | from starlette.requests import Request 19 | from starlette.responses import HTMLResponse, Response 20 | from starlette.templating import Jinja2Templates 21 | from starlette_cramjam.middleware import CompressionMiddleware 22 | from typing_extensions import Annotated 23 | 24 | from rio_viz.resources.enums import RasterFormat, VectorTileFormat 25 | 26 | from titiler.core.algorithm import algorithms as available_algorithms 27 | from titiler.core.dependencies import ( 28 | AssetsBidxExprParamsOptional, 29 | AssetsBidxParams, 30 | AssetsParams, 31 | BandsExprParamsOptional, 32 | BandsParams, 33 | BidxExprParams, 34 | ColorMapParams, 35 | CoordCRSParams, 36 | CRSParams, 37 | DatasetParams, 38 | DefaultDependency, 39 | DstCRSParams, 40 | HistogramParams, 41 | ImageRenderingParams, 42 | PartFeatureParams, 43 | PreviewParams, 44 | StatisticsParams, 45 | TileParams, 46 | ) 47 | from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers 48 | from titiler.core.middleware import CacheControlMiddleware 49 | from titiler.core.models.mapbox import TileJSON 50 | from titiler.core.models.responses import ( 51 | InfoGeoJSON, 52 | MultiBaseInfo, 53 | MultiBaseInfoGeoJSON, 54 | ) 55 | from titiler.core.resources.responses import GeoJSONResponse, JSONResponse, XMLResponse 56 | from titiler.core.utils import render_image 57 | 58 | try: 59 | from rio_tiler_mvt import pixels_encoder # noqa 60 | 61 | has_mvt = True 62 | except ModuleNotFoundError: 63 | has_mvt = False 64 | pixels_encoder = None 65 | 66 | jinja2_env = jinja2.Environment( 67 | loader=jinja2.ChoiceLoader([jinja2.PackageLoader(__package__, "templates")]) 68 | ) 69 | templates = Jinja2Templates(env=jinja2_env) 70 | 71 | TileFormat = Union[RasterFormat, VectorTileFormat] 72 | 73 | 74 | @attr.s 75 | class viz: 76 | """Creates a very minimal slippy map tile server using fastAPI + Uvicorn.""" 77 | 78 | src_path: str = attr.ib() 79 | reader: Union[Type[BaseReader], Type[MultiBandReader], Type[MultiBaseReader]] = ( 80 | attr.ib(default=Reader) 81 | ) 82 | reader_params: Dict = attr.ib(factory=dict) 83 | app: FastAPI = attr.ib(factory=FastAPI) 84 | 85 | port: int = attr.ib(default=8080) 86 | host: str = attr.ib(default="127.0.0.1") 87 | config: Dict = attr.ib(factory=dict) 88 | 89 | minzoom: Optional[int] = attr.ib(default=None) 90 | maxzoom: Optional[int] = attr.ib(default=None) 91 | bounds: Optional[Tuple[float, float, float, float]] = attr.ib(default=None) 92 | 93 | layers: Optional[List[str]] = attr.ib(default=None) 94 | nodata: Optional[Union[str, int, float]] = attr.ib(default=None) 95 | 96 | geojson: Optional[Dict] = attr.ib(default=None) 97 | 98 | # cog / bands / assets 99 | reader_type: str = attr.ib(init=False) 100 | 101 | router: Optional[APIRouter] = attr.ib(init=False) 102 | 103 | statistics_dependency: Type[DefaultDependency] = attr.ib(init=False) 104 | layer_dependency: Type[DefaultDependency] = attr.ib(init=False) 105 | 106 | def __attrs_post_init__(self): 107 | """Update App.""" 108 | self.router = APIRouter() 109 | 110 | if issubclass(self.reader, (MultiBandReader)): 111 | self.reader_type = "bands" 112 | elif issubclass(self.reader, (MultiBaseReader)): 113 | self.reader_type = "assets" 114 | else: 115 | self.reader_type = "cog" 116 | 117 | if self.reader_type == "cog": 118 | # For simple BaseReader (e.g Reader) we don't add more dependencies. 119 | self.info_dependency = DefaultDependency 120 | self.statistics_dependency = BidxExprParams 121 | self.layer_dependency = BidxExprParams 122 | 123 | elif self.reader_type == "bands": 124 | self.info_dependency = BandsParams 125 | self.statistics_dependency = BandsExprParamsOptional 126 | self.layer_dependency = BandsExprParamsOptional 127 | 128 | elif self.reader_type == "assets": 129 | self.info_dependency = AssetsParams 130 | self.statistics_dependency = AssetsBidxParams 131 | self.layer_dependency = AssetsBidxExprParamsOptional 132 | 133 | self.register_middleware() 134 | self.register_routes() 135 | self.app.include_router(self.router) 136 | add_exception_handlers(self.app, DEFAULT_STATUS_CODES) 137 | 138 | def register_middleware(self): 139 | """Register Middleware to the FastAPI app.""" 140 | self.app.add_middleware( 141 | CompressionMiddleware, 142 | minimum_size=0, 143 | exclude_mediatype={ 144 | "image/jpeg", 145 | "image/jpg", 146 | "image/png", 147 | "image/jp2", 148 | "image/webp", 149 | }, 150 | ) 151 | self.app.add_middleware( 152 | CORSMiddleware, 153 | allow_origins=["*"], 154 | allow_credentials=True, 155 | allow_methods=["GET"], 156 | allow_headers=["*"], 157 | ) 158 | self.app.add_middleware(CacheControlMiddleware, cachecontrol="no-cache") 159 | 160 | def _update_params(self, src_dst, options: Type[DefaultDependency]): 161 | """Create Reader options.""" 162 | if not getattr(options, "expression", None): 163 | if self.reader_type == "bands": 164 | # get default bands from self.layers or reader.bands 165 | bands = self.layers or getattr(src_dst, "bands", None) 166 | # check if bands is not in options and overwrite 167 | if bands and not getattr(options, "bands", None): 168 | options.bands = bands 169 | 170 | if self.reader_type == "assets": 171 | # get default assets from self.layers or reader.assets 172 | assets = self.layers or getattr(src_dst, "assets", None) 173 | # check if assets is not in options and overwrite 174 | if assets and not getattr(options, "assets", None): 175 | options.assets = assets 176 | 177 | def register_routes(self): # noqa 178 | """Register routes to the FastAPI app.""" 179 | img_media_types = { 180 | "image/png": {}, 181 | "image/jpeg": {}, 182 | "image/webp": {}, 183 | "image/jp2": {}, 184 | "image/tiff; application=geotiff": {}, 185 | "application/x-binary": {}, 186 | } 187 | mvt_media_types = { 188 | "application/x-binary": {}, 189 | "application/x-protobuf": {}, 190 | } 191 | 192 | @self.router.get( 193 | "/info", 194 | # for MultiBaseReader the output in `Dict[str, Info]` 195 | response_model=MultiBaseInfo if self.reader_type == "assets" else Info, 196 | response_model_exclude_none=True, 197 | response_class=JSONResponse, 198 | responses={200: {"description": "Return the info of the COG."}}, 199 | tags=["API"], 200 | ) 201 | def info(params=Depends(self.info_dependency)): 202 | """Handle /info requests.""" 203 | with self.reader(self.src_path, **self.reader_params) as src_dst: 204 | # Adapt options for each reader type 205 | self._update_params(src_dst, params) 206 | return src_dst.info(**params.as_dict()) 207 | 208 | @self.router.get( 209 | "/info.geojson", 210 | # for MultiBaseReader the output in `Dict[str, Info]` 211 | response_model=MultiBaseInfoGeoJSON 212 | if self.reader_type == "assets" 213 | else InfoGeoJSON, 214 | response_model_exclude_none=True, 215 | response_class=GeoJSONResponse, 216 | responses={ 217 | 200: { 218 | "content": {"application/geo+json": {}}, 219 | "description": "Return dataset's basic info as a GeoJSON feature.", 220 | } 221 | }, 222 | tags=["API"], 223 | ) 224 | def info_geojson( 225 | params=Depends(self.info_dependency), 226 | crs=Depends(CRSParams), 227 | ): 228 | """Handle /info requests.""" 229 | with self.reader(self.src_path, **self.reader_params) as src_dst: 230 | bounds = src_dst.get_geographic_bounds(crs or WGS84_CRS) 231 | if bounds[0] > bounds[2]: 232 | pl = Polygon.from_bounds(-180, bounds[1], bounds[2], bounds[3]) 233 | pr = Polygon.from_bounds(bounds[0], bounds[1], 180, bounds[3]) 234 | geometry = MultiPolygon( 235 | type="MultiPolygon", 236 | coordinates=[pl.coordinates, pr.coordinates], 237 | ) 238 | else: 239 | geometry = Polygon.from_bounds(*bounds) 240 | 241 | # Adapt options for each reader type 242 | self._update_params(src_dst, params) 243 | return Feature( 244 | type="Feature", 245 | bbox=bounds, 246 | geometry=geometry, 247 | properties=src_dst.info(**params.as_dict()), 248 | ) 249 | 250 | @self.router.get( 251 | "/statistics", 252 | # for MultiBaseReader the output in `Dict[str, Dict[str, ImageStatistics]]` 253 | response_model=Dict[str, Dict[str, BandStatistics]] 254 | if self.reader_type == "assets" 255 | else Dict[str, BandStatistics], 256 | response_model_exclude_none=True, 257 | response_class=JSONResponse, 258 | responses={200: {"description": "Return the statistics of the COG."}}, 259 | tags=["API"], 260 | ) 261 | def statistics( 262 | layer_params=Depends(self.statistics_dependency), 263 | image_params: PreviewParams = Depends(), 264 | dataset_params: DatasetParams = Depends(), 265 | stats_params: StatisticsParams = Depends(), 266 | histogram_params: HistogramParams = Depends(), 267 | ): 268 | """Handle /stats requests.""" 269 | with self.reader(self.src_path, **self.reader_params) as src_dst: 270 | if self.nodata is not None and dataset_params.nodata is None: 271 | dataset_params.nodata = self.nodata 272 | 273 | # Adapt options for each reader type 274 | self._update_params(src_dst, layer_params) 275 | 276 | return src_dst.statistics( 277 | **layer_params.as_dict(), 278 | **dataset_params.as_dict(), 279 | **image_params.as_dict(), 280 | **stats_params.as_dict(), 281 | hist_options=histogram_params.as_dict(), 282 | ) 283 | 284 | @self.router.get( 285 | "/point", 286 | responses={200: {"description": "Return a point value."}}, 287 | response_class=JSONResponse, 288 | tags=["API"], 289 | ) 290 | def point( 291 | coordinates: Annotated[ 292 | str, 293 | Query(description="Coma (',') delimited lon,lat coordinates"), 294 | ], 295 | layer_params=Depends(self.layer_dependency), 296 | dataset_params: DatasetParams = Depends(), 297 | ): 298 | """Handle /point requests.""" 299 | lon, lat = list(map(float, coordinates.split(","))) 300 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 301 | if self.nodata is not None and dataset_params.nodata is None: 302 | dataset_params.nodata = self.nodata 303 | 304 | # Adapt options for each reader type 305 | self._update_params(src_dst, layer_params) 306 | 307 | pts = src_dst.point( 308 | lon, 309 | lat, 310 | **layer_params.as_dict(), 311 | **dataset_params.as_dict(), 312 | ) 313 | 314 | return { 315 | "coordinates": [lon, lat], 316 | "values": pts.array.tolist(), 317 | "band_names": pts.band_names, 318 | } 319 | 320 | preview_params = { 321 | "responses": { 322 | 200: {"content": img_media_types, "description": "Return a preview."} 323 | }, 324 | "response_class": Response, 325 | "description": "Return a preview.", 326 | } 327 | 328 | @self.router.get("/preview", **preview_params, tags=["API"]) 329 | @self.router.get("/preview.{format}", **preview_params, tags=["API"]) 330 | def preview( 331 | format: Optional[RasterFormat] = None, 332 | layer_params=Depends(self.layer_dependency), 333 | img_params: PreviewParams = Depends(), 334 | dataset_params: DatasetParams = Depends(), 335 | render_params: ImageRenderingParams = Depends(), 336 | colormap: ColorMapParams = Depends(), 337 | post_process=Depends(available_algorithms.dependency), 338 | ): 339 | """Handle /preview requests.""" 340 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 341 | if self.nodata is not None and dataset_params.nodata is None: 342 | dataset_params.nodata = self.nodata 343 | 344 | # Adapt options for each reader type 345 | self._update_params(src_dst, layer_params) 346 | 347 | image = src_dst.preview( 348 | **layer_params.as_dict(), 349 | **dataset_params.as_dict(), 350 | **img_params.as_dict(), 351 | ) 352 | dst_colormap = getattr(src_dst, "colormap", None) 353 | 354 | if post_process: 355 | image = post_process(image) 356 | 357 | content, media_type = render_image( 358 | image, 359 | output_format=format, 360 | colormap=colormap or dst_colormap, 361 | **render_params.as_dict(), 362 | ) 363 | 364 | return Response(content, media_type=media_type) 365 | 366 | part_params = { 367 | "responses": { 368 | 200: { 369 | "content": img_media_types, 370 | "description": "Return a part of a dataset.", 371 | } 372 | }, 373 | "response_class": Response, 374 | "description": "Return a part of a dataset.", 375 | } 376 | 377 | @self.router.get( 378 | "/bbox/{minx},{miny},{maxx},{maxy}.{format}", 379 | **part_params, 380 | tags=["API"], 381 | ) 382 | @self.router.get( 383 | "/bbox/{minx},{miny},{maxx},{maxy}/{width}x{height}.{format}", 384 | **part_params, 385 | tags=["API"], 386 | ) 387 | def part( 388 | minx: Annotated[float, Path(description="Bounding box min X")], 389 | miny: Annotated[float, Path(description="Bounding box min Y")], 390 | maxx: Annotated[float, Path(description="Bounding box max X")], 391 | maxy: Annotated[float, Path(description="Bounding box max Y")], 392 | format: Annotated[ 393 | RasterFormat, 394 | "Output image type.", 395 | ] = RasterFormat.png, 396 | layer_params=Depends(self.layer_dependency), 397 | img_params: PartFeatureParams = Depends(), 398 | dataset_params: DatasetParams = Depends(), 399 | render_params: ImageRenderingParams = Depends(), 400 | colormap: ColorMapParams = Depends(), 401 | dst_crs=Depends(DstCRSParams), 402 | coord_crs=Depends(CoordCRSParams), 403 | post_process=Depends(available_algorithms.dependency), 404 | ): 405 | """Create image from part of a dataset.""" 406 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 407 | if self.nodata is not None and dataset_params.nodata is None: 408 | dataset_params.nodata = self.nodata 409 | 410 | # Adapt options for each reader type 411 | self._update_params(src_dst, layer_params) 412 | 413 | image = src_dst.part( 414 | [minx, miny, maxx, maxy], 415 | dst_crs=dst_crs, 416 | bounds_crs=coord_crs or WGS84_CRS, 417 | **layer_params.as_dict(), 418 | **dataset_params.as_dict(), 419 | **img_params.as_dict(), 420 | ) 421 | dst_colormap = getattr(src_dst, "colormap", None) 422 | 423 | if post_process: 424 | image = post_process(image) 425 | 426 | content, media_type = render_image( 427 | image, 428 | output_format=format, 429 | colormap=colormap or dst_colormap, 430 | **render_params.as_dict(), 431 | ) 432 | 433 | return Response(content, media_type=media_type) 434 | 435 | feature_params = { 436 | "responses": { 437 | 200: { 438 | "content": img_media_types, 439 | "description": "Return part of a dataset defined by a geojson feature.", 440 | } 441 | }, 442 | "response_class": Response, 443 | "description": "Return part of a dataset defined by a geojson feature.", 444 | } 445 | 446 | @self.router.post("/feature", **feature_params, tags=["API"]) 447 | @self.router.post("/feature.{format}", **feature_params, tags=["API"]) 448 | @self.router.post( 449 | "/feature/{width}x{height}.{format}", **feature_params, tags=["API"] 450 | ) 451 | def geojson_part( 452 | geom: Feature, 453 | format: Annotated[Optional[RasterFormat], "Output image type."] = None, 454 | layer_params=Depends(self.layer_dependency), 455 | img_params: PartFeatureParams = Depends(), 456 | dataset_params: DatasetParams = Depends(), 457 | render_params: ImageRenderingParams = Depends(), 458 | colormap: ColorMapParams = Depends(), 459 | post_process=Depends(available_algorithms.dependency), 460 | ): 461 | """Handle /feature requests.""" 462 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 463 | if self.nodata is not None and dataset_params.nodata is None: 464 | dataset_params.nodata = self.nodata 465 | 466 | # Adapt options for each reader type 467 | self._update_params(src_dst, layer_params) 468 | 469 | image = src_dst.feature( 470 | geom.model_dump(exclude_none=True), 471 | **layer_params.as_dict(), 472 | **dataset_params.as_dict(), 473 | ) 474 | dst_colormap = getattr(src_dst, "colormap", None) 475 | 476 | if post_process: 477 | image = post_process(image) 478 | 479 | content, media_type = render_image( 480 | image, 481 | output_format=format, 482 | colormap=colormap or dst_colormap, 483 | **render_params.as_dict(), 484 | ) 485 | 486 | return Response(content, media_type=media_type) 487 | 488 | tile_params = { 489 | "responses": { 490 | 200: { 491 | "content": {**img_media_types, **mvt_media_types}, 492 | "description": "Return a tile.", 493 | } 494 | }, 495 | "response_class": Response, 496 | "description": "Read COG and return a tile", 497 | } 498 | 499 | @self.router.get( 500 | "/tiles/WebMercatorQuad/{z}/{x}/{y}", **tile_params, tags=["API"] 501 | ) 502 | @self.router.get( 503 | "/tiles/WebMercatorQuad/{z}/{x}/{y}.{format}", **tile_params, tags=["API"] 504 | ) 505 | def tile( 506 | z: Annotated[ 507 | int, 508 | Path( 509 | description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.", 510 | ), 511 | ], 512 | x: Annotated[ 513 | int, 514 | Path( 515 | description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.", 516 | ), 517 | ], 518 | y: Annotated[ 519 | int, 520 | Path( 521 | description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.", 522 | ), 523 | ], 524 | format: Annotated[TileFormat, "Output tile type."] = None, 525 | layer_params=Depends(self.layer_dependency), 526 | dataset_params: DatasetParams = Depends(), 527 | render_params: ImageRenderingParams = Depends(), 528 | tile_params: TileParams = Depends(), 529 | colormap: ColorMapParams = Depends(), 530 | feature_type: Annotated[ 531 | Optional[Literal["point", "polygon"]], 532 | Query(title="Feature type (Only for MVT)"), 533 | ] = None, 534 | tilesize: Annotated[ 535 | Optional[int], 536 | Query(description="Tile Size."), 537 | ] = None, 538 | post_process=Depends(available_algorithms.dependency), 539 | ): 540 | """Handle /tiles requests.""" 541 | default_tilesize = 256 542 | 543 | if format and format in VectorTileFormat: 544 | default_tilesize = 128 545 | 546 | tilesize = tilesize or default_tilesize 547 | 548 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 549 | if self.nodata is not None and dataset_params.nodata is None: 550 | dataset_params.nodata = self.nodata 551 | 552 | # Adapt options for each reader type 553 | self._update_params(src_dst, layer_params) 554 | 555 | image = src_dst.tile( 556 | x, 557 | y, 558 | z, 559 | tilesize=tilesize, 560 | **tile_params.as_dict(), 561 | **layer_params.as_dict(), 562 | **dataset_params.as_dict(), 563 | ) 564 | 565 | dst_colormap = getattr(src_dst, "colormap", None) 566 | 567 | # Vector Tile 568 | if format and format in VectorTileFormat: 569 | if not pixels_encoder: 570 | raise HTTPException( 571 | status_code=500, 572 | detail="rio-tiler-mvt not found, please do pip install rio-viz['mvt']", 573 | ) 574 | 575 | if not feature_type: 576 | raise HTTPException( 577 | status_code=500, 578 | detail="missing feature_type for vector tile.", 579 | ) 580 | 581 | content = pixels_encoder( 582 | image.data, 583 | image.mask, 584 | image.band_names, 585 | feature_type=feature_type, 586 | ) 587 | 588 | media_type = format.mediatype 589 | 590 | # Raster Tile 591 | else: 592 | if post_process: 593 | image = post_process(image) 594 | 595 | content, media_type = render_image( 596 | image, 597 | output_format=format, 598 | colormap=colormap or dst_colormap, 599 | **render_params.as_dict(), 600 | ) 601 | 602 | return Response(content, media_type=media_type) 603 | 604 | @self.router.get( 605 | "/tilejson.json", 606 | response_model=TileJSON, 607 | responses={200: {"description": "Return a tilejson"}}, 608 | response_model_exclude_none=True, 609 | tags=["API"], 610 | ) 611 | def tilejson( 612 | request: Request, 613 | tile_format: Annotated[ 614 | Optional[TileFormat], 615 | "Output tile type.", 616 | ] = None, 617 | layer_params=Depends(self.layer_dependency), 618 | dataset_params: DatasetParams = Depends(), 619 | render_params: ImageRenderingParams = Depends(), 620 | colormap: ColorMapParams = Depends(), 621 | post_process=Depends(available_algorithms.dependency), 622 | feature_type: Annotated[ 623 | Optional[Literal["point", "polygon"]], 624 | Query(title="Feature type (Only for MVT)"), 625 | ] = None, 626 | tilesize: Annotated[ 627 | Optional[int], 628 | Query(description="Tile Size."), 629 | ] = None, 630 | ): 631 | """Handle /tilejson.json requests.""" 632 | kwargs: Dict[str, Any] = {"z": "{z}", "x": "{x}", "y": "{y}"} 633 | if tile_format: 634 | kwargs["format"] = tile_format.value 635 | 636 | tile_url = str(request.url_for("tile", **kwargs)) 637 | 638 | qs = [ 639 | (key, value) 640 | for (key, value) in request.query_params._list 641 | if key not in ["tile_format"] 642 | ] 643 | if qs: 644 | tile_url += f"?{urllib.parse.urlencode(qs)}" 645 | 646 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 647 | bounds = ( 648 | self.bounds 649 | if self.bounds is not None 650 | else src_dst.get_geographic_bounds( 651 | src_dst.tms.rasterio_geographic_crs 652 | ) 653 | ) 654 | minzoom = self.minzoom if self.minzoom is not None else src_dst.minzoom 655 | maxzoom = self.maxzoom if self.maxzoom is not None else src_dst.maxzoom 656 | 657 | return { 658 | "bounds": bounds, 659 | "minzoom": minzoom, 660 | "maxzoom": maxzoom, 661 | "name": "rio-viz", 662 | "tilejson": "2.1.0", 663 | "tiles": [tile_url], 664 | } 665 | 666 | @self.router.get( 667 | "/WMTSCapabilities.xml", response_class=XMLResponse, tags=["API"] 668 | ) 669 | def wmts( 670 | request: Request, 671 | tile_format: Annotated[ 672 | TileFormat, 673 | Query(description="Output image type. Default is png."), 674 | ] = RasterFormat.png, 675 | layer_params=Depends(self.layer_dependency), 676 | dataset_params: DatasetParams = Depends(), 677 | render_params: ImageRenderingParams = Depends(), 678 | colormap: ColorMapParams = Depends(), 679 | post_process=Depends(available_algorithms.dependency), 680 | feature_type: Annotated[ 681 | Optional[Literal["point", "polygon"]], 682 | Query(title="Feature type (Only for MVT)"), 683 | ] = None, 684 | ): 685 | """ 686 | This is a hidden gem. 687 | 688 | rio-viz is meant to be use to visualize your dataset in the browser but 689 | using this endpoint, you can also load it in you GIS software. 690 | 691 | """ 692 | kwargs = { 693 | "z": "{TileMatrix}", 694 | "x": "{TileCol}", 695 | "y": "{TileRow}", 696 | "format": tile_format.value, 697 | } 698 | tiles_endpoint = str(request.url_for("tile", **kwargs)) 699 | 700 | qs = [ 701 | (key, value) 702 | for (key, value) in request.query_params._list 703 | if key not in ["tile_format", "REQUEST", "SERVICE"] 704 | ] 705 | if qs: 706 | tiles_endpoint += f"?{urllib.parse.urlencode(qs)}" 707 | 708 | with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore 709 | bounds = ( 710 | self.bounds 711 | if self.bounds is not None 712 | else src_dst.get_geographic_bounds( 713 | src_dst.tms.rasterio_geographic_crs 714 | ) 715 | ) 716 | minzoom = self.minzoom if self.minzoom is not None else src_dst.minzoom 717 | maxzoom = self.maxzoom if self.maxzoom is not None else src_dst.maxzoom 718 | 719 | tileMatrix = [] 720 | for zoom in range(minzoom, maxzoom + 1): # type: ignore 721 | tm = f""" 722 | {zoom} 723 | {559082264.02872 / 2 ** zoom / 1} 724 | -20037508.34278925 20037508.34278925 725 | 256 726 | 256 727 | {2 ** zoom} 728 | {2 ** zoom} 729 | """ 730 | tileMatrix.append(tm) 731 | 732 | return templates.TemplateResponse( 733 | request, 734 | name="wmts.xml", 735 | context={ 736 | "tiles_endpoint": tiles_endpoint, 737 | "bounds": bounds, 738 | "tileMatrix": tileMatrix, 739 | "title": "Cloud Optimized GeoTIFF", 740 | "layer_name": "cogeo", 741 | "media_type": tile_format.mediatype, 742 | }, 743 | media_type="application/xml", 744 | ) 745 | 746 | @self.router.get("/map", response_class=HTMLResponse) 747 | def map_viewer( 748 | request: Request, 749 | tile_format: Annotated[ 750 | Optional[RasterFormat], 751 | Query(description="Output raster tile type."), 752 | ] = None, 753 | layer_params=Depends(self.layer_dependency), 754 | dataset_params: DatasetParams = Depends(), 755 | render_params: ImageRenderingParams = Depends(), 756 | colormap: ColorMapParams = Depends(), 757 | post_process=Depends(available_algorithms.dependency), 758 | tilesize: Annotated[ 759 | Optional[int], 760 | Query(description="Tile Size."), 761 | ] = None, 762 | ): 763 | """Return a simple map viewer.""" 764 | tilejson_url = str(request.url_for("tilejson")) 765 | 766 | if request.query_params: 767 | tilejson_url += f"?{request.query_params}" 768 | 769 | return templates.TemplateResponse( 770 | request, 771 | name="map.html", 772 | context={ 773 | "tilejson_endpoint": tilejson_url, 774 | }, 775 | media_type="text/html", 776 | ) 777 | 778 | @self.router.get( 779 | "/", 780 | responses={200: {"description": "Simple COG viewer."}}, 781 | response_class=HTMLResponse, 782 | tags=["Viewer"], 783 | ) 784 | @self.router.get( 785 | "/index.html", 786 | responses={200: {"description": "Simple COG viewer."}}, 787 | response_class=HTMLResponse, 788 | tags=["Viewer"], 789 | ) 790 | def viewer(request: Request): 791 | """Handle /index.html.""" 792 | if self.reader_type == "cog": 793 | name = "index.html" 794 | elif self.reader_type == "bands": 795 | name = "bands.html" 796 | elif self.reader_type == "assets": 797 | name = "assets.html" 798 | 799 | return templates.TemplateResponse( 800 | request, 801 | name=name, 802 | context={ 803 | "tilejson_endpoint": str(request.url_for("tilejson")), 804 | "stats_endpoint": str(request.url_for("statistics")), 805 | "info_endpoint": str(request.url_for("info_geojson")), 806 | "point_endpoint": str(request.url_for("point")), 807 | "allow_3d": has_mvt, 808 | "geojson": self.geojson, 809 | }, 810 | media_type="text/html", 811 | ) 812 | 813 | @property 814 | def endpoint(self) -> str: 815 | """Get endpoint url.""" 816 | return f"http://{self.host}:{self.port}" 817 | 818 | @property 819 | def template_url(self) -> str: 820 | """Get simple app template url.""" 821 | return f"http://{self.host}:{self.port}/index.html" 822 | 823 | @property 824 | def docs_url(self) -> str: 825 | """Get simple app template url.""" 826 | return f"http://{self.host}:{self.port}/docs" 827 | 828 | def start(self): 829 | """Start tile server.""" 830 | with rasterio.Env(**self.config): 831 | uvicorn.run(app=self.app, host=self.host, port=self.port, log_level="info") 832 | 833 | 834 | @attr.s 835 | class Client(viz): 836 | """Create a Client usable in Jupyter Notebook.""" 837 | 838 | server: ServerThread = attr.ib(init=False) 839 | 840 | def __attrs_post_init__(self): 841 | """Update App.""" 842 | super().__attrs_post_init__() 843 | 844 | key = f"{self.host}:{self.port}" 845 | if ServerManager.is_server_live(key): 846 | ServerManager.shutdown_server(key) 847 | 848 | self.server = ServerThread(self.app, port=self.port, host=self.host) 849 | ServerManager.add_server(key, self.server) 850 | 851 | def shutdown(self): 852 | """Stop server""" 853 | ServerManager.shutdown_server(f"{self.host}:{self.port}") 854 | -------------------------------------------------------------------------------- /rio_viz/io/__init__.py: -------------------------------------------------------------------------------- 1 | """rio_viz.io.""" 2 | 3 | from rio_viz.io.mosaic import MosaicReader # noqa 4 | from rio_viz.io.reader import MultiFilesAssetsReader, MultiFilesBandsReader # noqa 5 | -------------------------------------------------------------------------------- /rio_viz/io/mosaic.py: -------------------------------------------------------------------------------- 1 | """rio-viz mosaic reader.""" 2 | 3 | from typing import Any, Dict, Type 4 | 5 | import attr 6 | from braceexpand import braceexpand 7 | from morecantile import TileMatrixSet 8 | from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS 9 | from rio_tiler.io import BaseReader, COGReader 10 | from rio_tiler.models import BandStatistics, ImageData, Info, PointData 11 | from rio_tiler.mosaic import mosaic_point_reader, mosaic_reader 12 | 13 | 14 | @attr.s 15 | class MosaicReader(BaseReader): 16 | """Simple Mosaic reader. 17 | 18 | Args: 19 | input (str): Brace Expandable path (e.g: file{1,2,3}.tif). 20 | 21 | """ 22 | 23 | input: str = attr.ib() 24 | tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) 25 | 26 | reader: Type[COGReader] = attr.ib(default=COGReader) 27 | 28 | colormap: Dict = attr.ib(init=False) 29 | 30 | datasets: Dict[str, Type[COGReader]] = attr.ib(init=False) 31 | 32 | def __attrs_post_init__(self): 33 | """Fetch Reference band to get the bounds.""" 34 | self.datasets = { 35 | src_path: self.reader(src_path, tms=self.tms) 36 | for src_path in braceexpand(self.input) 37 | } 38 | 39 | self.minzoom = min([cog.minzoom for cog in self.datasets.values()]) 40 | self.maxzoom = max([cog.maxzoom for cog in self.datasets.values()]) 41 | 42 | self.crs = WGS84_CRS 43 | bounds = [cog.get_geographic_bounds(WGS84_CRS) for cog in self.datasets.values()] 44 | minx, miny, maxx, maxy = zip(*bounds) 45 | self.bounds = [min(minx), min(miny), max(maxx), max(maxy)] 46 | 47 | # check for unique dtype 48 | dtypes = {cog.dataset.dtypes[0] for cog in self.datasets.values()} 49 | if len(dtypes) > 1: 50 | raise Exception("Datasets must be of the same data type.") 51 | 52 | # check for same number of band 53 | nbands = {cog.dataset.count for cog in self.datasets.values()} 54 | if len(nbands) > 1: 55 | raise Exception("Datasets must be have the same number of bands.") 56 | 57 | cmaps = [cog.colormap for cog in self.datasets.values() if cog.colormap] 58 | if len(cmaps) > 0: 59 | # !!! We take the first one ¡¡¡ 60 | self.colormap = list(cmaps)[0] 61 | 62 | def __exit__(self, exc_type, exc_value, traceback): 63 | """Support using with Context Managers.""" 64 | for dataset in self.datasets.values(): 65 | dataset.close() 66 | 67 | def tile( 68 | self, 69 | tile_x: int, 70 | tile_y: int, 71 | tile_z: int, 72 | reverse: bool = False, 73 | **kwargs: Any, 74 | ) -> ImageData: 75 | """Get Tile.""" 76 | mosaic_assets = ( 77 | list(reversed(list(self.datasets))) if reverse else list(self.datasets) 78 | ) 79 | 80 | def _reader( 81 | asset: str, tile_x: int, tile_y: int, tile_z: int, **kwargs: Any 82 | ) -> ImageData: 83 | return self.datasets[asset].tile(tile_x, tile_y, tile_z, **kwargs) 84 | 85 | return mosaic_reader( 86 | mosaic_assets, _reader, tile_x, tile_y, tile_z, threads=0, **kwargs 87 | )[0] 88 | 89 | def point( 90 | self, 91 | lon: float, 92 | lat: float, 93 | reverse: bool = False, 94 | **kwargs: Any, 95 | ) -> PointData: 96 | """Get Point value.""" 97 | mosaic_assets = ( 98 | list(reversed(list(self.datasets))) if reverse else list(self.datasets) 99 | ) 100 | 101 | def _reader(asset: str, lon: float, lat: float, **kwargs) -> PointData: 102 | return self.datasets[asset].point(lon, lat, **kwargs) 103 | 104 | return mosaic_point_reader(mosaic_assets, _reader, lon, lat, threads=0, **kwargs)[ 105 | 0 106 | ] 107 | 108 | def info(self) -> Info: 109 | """info.""" 110 | # !!! We return info from the first dataset 111 | # Most of the info should be similar in other files ¡¡¡ 112 | item = list(self.datasets)[0] 113 | info_metadata = ( 114 | self.datasets[item] 115 | .info() 116 | .model_dump( 117 | exclude={ 118 | "bounds", 119 | "minzoom", 120 | "maxzoom", 121 | "width", 122 | "height", 123 | "overviews", 124 | }, 125 | ) 126 | ) 127 | info_metadata["bounds"] = self.bounds 128 | info_metadata["minzoom"] = self.minzoom 129 | info_metadata["maxzoom"] = self.maxzoom 130 | return Info(**info_metadata) 131 | 132 | def statistics(self, **kwargs: Any) -> Dict[str, BandStatistics]: 133 | """Return Dataset's statistics.""" 134 | # FOR NOW WE ONLY RETURN VALUE FROM THE FIRST FILE 135 | item = list(self.datasets)[0] 136 | return self.datasets[item].statistics(**kwargs) 137 | 138 | ############################################################################ 139 | # Not Implemented methods 140 | # BaseReader required those method to be implemented 141 | def preview(self, *args, **kwargs): 142 | """Placeholder for BaseReader.preview.""" 143 | raise NotImplementedError 144 | 145 | def part(self, *args, **kwargs): 146 | """Placeholder for BaseReader.part.""" 147 | raise NotImplementedError 148 | 149 | def feature(self, *args, **kwargs): 150 | """Placeholder for BaseReader.feature.""" 151 | raise NotImplementedError 152 | -------------------------------------------------------------------------------- /rio_viz/io/reader.py: -------------------------------------------------------------------------------- 1 | """rio-viz multifile reader.""" 2 | 3 | from typing import List, Type 4 | 5 | import attr 6 | from braceexpand import braceexpand 7 | from rio_tiler import io 8 | from rio_tiler.errors import InvalidBandName 9 | from rio_tiler.types import AssetInfo 10 | 11 | 12 | @attr.s 13 | class MultiFilesBandsReader(io.MultiBandReader): 14 | """Multiple Files as Bands.""" 15 | 16 | reader: Type[io.BaseReader] = attr.ib(default=io.Reader, init=False) 17 | 18 | _files: List[str] = attr.ib(init=False) 19 | 20 | def __attrs_post_init__(self): 21 | """Fetch Reference band to get the bounds.""" 22 | self._files = list(braceexpand(self.input)) 23 | self.bands = [f"b{ix + 1}" for ix in range(len(self._files))] 24 | 25 | with self.reader(self._files[0], tms=self.tms, **self.reader_options) as cog: 26 | self.bounds = cog.bounds 27 | self.crs = cog.crs 28 | self.minzoom = cog.minzoom 29 | self.maxzoom = cog.maxzoom 30 | 31 | def _get_band_url(self, band: str) -> str: 32 | """Validate band's name and return band's url.""" 33 | if band not in self.bands: 34 | raise InvalidBandName(f"{band} is not valid") 35 | 36 | index = self.bands.index(band) 37 | return self._files[index] 38 | 39 | 40 | @attr.s 41 | class MultiFilesAssetsReader(io.MultiBaseReader): 42 | """Multiple Files as Assets.""" 43 | 44 | reader: Type[io.BaseReader] = attr.ib(default=io.Reader, init=False) 45 | 46 | _files: List[str] = attr.ib(init=False) 47 | 48 | def __attrs_post_init__(self): 49 | """Fetch Reference band to get the bounds.""" 50 | self._files = list(braceexpand(self.input)) 51 | self.assets = [f"asset{ix + 1}" for ix in range(len(self._files))] 52 | 53 | with self.reader(self._files[0], tms=self.tms, **self.reader_options) as cog: 54 | self.bounds = cog.bounds 55 | self.crs = cog.crs 56 | self.minzoom = cog.minzoom 57 | self.maxzoom = cog.maxzoom 58 | 59 | def _get_asset_info(self, asset: str) -> AssetInfo: 60 | """Validate band's name and return band's url.""" 61 | if asset not in self.assets: 62 | raise InvalidBandName(f"{asset} is not valid") 63 | 64 | index = self.assets.index(asset) 65 | return AssetInfo(url=self._files[index]) 66 | -------------------------------------------------------------------------------- /rio_viz/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """titiler.resources.""" 2 | -------------------------------------------------------------------------------- /rio_viz/resources/enums.py: -------------------------------------------------------------------------------- 1 | """rio-viz Enums.""" 2 | 3 | from enum import Enum 4 | from types import DynamicClassAttribute 5 | 6 | from rio_tiler.profiles import img_profiles 7 | 8 | 9 | class MediaType(str, Enum): 10 | """Responses Media types formerly known as MIME types.""" 11 | 12 | tif = "image/tiff; application=geotiff" 13 | jp2 = "image/jp2" 14 | png = "image/png" 15 | pngraw = "image/png" 16 | jpeg = "image/jpeg" 17 | jpg = "image/jpg" 18 | webp = "image/webp" 19 | npy = "application/x-binary" 20 | xml = "application/xml" 21 | json = "application/json" 22 | geojson = "application/geo+json" 23 | html = "text/html" 24 | text = "text/plain" 25 | pbf = "application/x-protobuf" 26 | mvt = "application/x-protobuf" 27 | 28 | 29 | class ImageDriver(str, Enum): 30 | """Supported output GDAL drivers.""" 31 | 32 | jpeg = "JPEG" 33 | jpg = "JPEG" 34 | png = "PNG" 35 | pngraw = "PNG" 36 | tif = "GTiff" 37 | webp = "WEBP" 38 | jp2 = "JP2OpenJPEG" 39 | npy = "NPY" 40 | 41 | 42 | class DataFormat(str, Enum): 43 | """Data Format Base Class.""" 44 | 45 | @DynamicClassAttribute 46 | def profile(self): 47 | """Return rio-tiler image default profile.""" 48 | return img_profiles.get(self._name_, {}) 49 | 50 | @DynamicClassAttribute 51 | def driver(self): 52 | """Return rio-tiler image default profile.""" 53 | try: 54 | return ImageDriver[self._name_].value 55 | except KeyError: 56 | return "" 57 | 58 | @DynamicClassAttribute 59 | def mediatype(self): 60 | """Return image mimetype.""" 61 | return MediaType[self._name_].value 62 | 63 | 64 | class RasterFormat(DataFormat): 65 | """Available Output Raster format.""" 66 | 67 | png = "png" 68 | npy = "npy" 69 | tif = "tif" 70 | jpeg = "jpeg" 71 | jpg = "jpg" 72 | jp2 = "jp2" 73 | webp = "webp" 74 | pngraw = "pngraw" 75 | 76 | 77 | class VectorTileFormat(DataFormat): 78 | """Available Output Vector Tile format.""" 79 | 80 | pbf = "pbf" 81 | mvt = "mvt" 82 | -------------------------------------------------------------------------------- /rio_viz/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """rio_viz cli.""" 2 | -------------------------------------------------------------------------------- /rio_viz/scripts/cli.py: -------------------------------------------------------------------------------- 1 | """rio_viz.cli.""" 2 | 3 | import importlib 4 | import json 5 | import os 6 | import tempfile 7 | import warnings 8 | from contextlib import ExitStack, contextmanager 9 | 10 | import click 11 | import numpy 12 | from rasterio.rio import options 13 | from rio_cogeo.cogeo import cog_translate, cog_validate 14 | from rio_cogeo.profiles import cog_profiles 15 | from rio_tiler.io import BaseReader, COGReader, MultiBandReader, MultiBaseReader 16 | 17 | from rio_viz import app 18 | 19 | 20 | def options_to_dict(ctx, param, value): 21 | """ 22 | click callback to validate `--opt KEY1=VAL1 --opt KEY2=VAL2` and collect 23 | in a dictionary like the one below, which is what the CLI function receives. 24 | If no value or `None` is received then an empty dictionary is returned. 25 | 26 | { 27 | 'KEY1': 'VAL1', 28 | 'KEY2': 'VAL2' 29 | } 30 | 31 | Note: `==VAL` breaks this as `str.split('=', 1)` is used. 32 | """ 33 | 34 | if not value: 35 | return {} 36 | else: 37 | out = {} 38 | for pair in value: 39 | if "=" not in pair: 40 | raise click.BadParameter(f"Invalid syntax for KEY=VAL arg: {pair}") 41 | else: 42 | k, v = pair.split("=", 1) 43 | out[k] = v 44 | 45 | return out 46 | 47 | 48 | @contextmanager 49 | def TemporaryRasterFile(suffix=".tif"): 50 | """Create temporary file.""" 51 | fileobj = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) 52 | fileobj.close() 53 | try: 54 | yield fileobj 55 | finally: 56 | os.remove(fileobj.name) 57 | 58 | 59 | class NodataParamType(click.ParamType): 60 | """Nodata index type.""" 61 | 62 | name = "nodata" 63 | 64 | def convert(self, value, param, ctx): 65 | """Validate and parse band index.""" 66 | try: 67 | if value.lower() == "nan": 68 | return numpy.nan 69 | elif value.lower() in ["nil", "none", "nada"]: 70 | return None 71 | else: 72 | return float(value) 73 | except (TypeError, ValueError) as e: 74 | raise click.ClickException( 75 | "{} is not a valid nodata value.".format(value) 76 | ) from e 77 | 78 | 79 | @click.command() 80 | @click.argument("src_path", type=str, nargs=1, required=True) 81 | @click.option( 82 | "--nodata", 83 | type=NodataParamType(), 84 | metavar="NUMBER|nan", 85 | help="Set nodata masking values for input dataset.", 86 | ) 87 | @click.option( 88 | "--minzoom", 89 | type=int, 90 | help="Overwrite minzoom", 91 | ) 92 | @click.option( 93 | "--maxzoom", 94 | type=int, 95 | help="Overwrite maxzoom", 96 | ) 97 | @click.option("--port", type=int, default=8080, help="Webserver port (default: 8080)") 98 | @click.option( 99 | "--host", 100 | type=str, 101 | default="127.0.0.1", 102 | help="Webserver host url (default: 127.0.0.1)", 103 | ) 104 | @click.option("--no-check", is_flag=True, help="Ignore COG validation") 105 | @click.option( 106 | "--reader", 107 | type=str, 108 | help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader`", 109 | ) 110 | @click.option( 111 | "--layers", 112 | type=str, 113 | help="limit to specific layers (only used for MultiBand and MultiBase Readers) (e.g --layers b1 --layers b2).", 114 | multiple=True, 115 | ) 116 | @click.option( 117 | "--server-only", 118 | is_flag=True, 119 | default=False, 120 | help="Launch API without opening the rio-viz web-page.", 121 | ) 122 | @click.option( 123 | "--config", 124 | "config", 125 | metavar="NAME=VALUE", 126 | multiple=True, 127 | callback=options._cb_key_val, 128 | help="GDAL configuration options.", 129 | ) 130 | @click.option( 131 | "--reader-params", 132 | "-p", 133 | "reader_params", 134 | metavar="NAME=VALUE", 135 | multiple=True, 136 | callback=options_to_dict, 137 | help="Reader Options.", 138 | ) 139 | @click.option( 140 | "--geojson", 141 | type=click.File(mode="r"), 142 | help="GeoJSON Feature or FeatureCollection path to display on viewer.", 143 | ) 144 | def viz( 145 | src_path, 146 | nodata, 147 | minzoom, 148 | maxzoom, 149 | port, 150 | host, 151 | no_check, 152 | reader, 153 | layers, 154 | server_only, 155 | config, 156 | reader_params, 157 | geojson, 158 | ): 159 | """Rasterio Viz cli.""" 160 | if reader: 161 | module, classname = reader.rsplit(".", 1) 162 | reader = getattr(importlib.import_module(module), classname) # noqa 163 | if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)): 164 | warnings.warn(f"Invalid reader type: {type(reader)}") 165 | 166 | dataset_reader = reader or COGReader 167 | 168 | # Check if cog 169 | with ExitStack() as ctx: 170 | if ( 171 | src_path.lower().endswith(".tif") 172 | and not reader 173 | and not no_check 174 | and not cog_validate(src_path)[0] 175 | ): 176 | # create tmp COG 177 | click.echo("create temporary COG") 178 | tmp_path = ctx.enter_context(TemporaryRasterFile()) 179 | 180 | output_profile = cog_profiles.get("deflate") 181 | output_profile.update({"blockxsize": "256", "blockysize": "256"}) 182 | config = {"GDAL_TIFF_INTERNAL_MASK": True, "GDAL_TIFF_OVR_BLOCKSIZE": "128"} 183 | cog_translate(src_path, tmp_path.name, output_profile, config=config) 184 | src_path = tmp_path.name 185 | 186 | application = app.viz( 187 | src_path=src_path, 188 | reader=dataset_reader, 189 | reader_params=reader_params, 190 | port=port, 191 | host=host, 192 | config=config, 193 | minzoom=minzoom, 194 | maxzoom=maxzoom, 195 | nodata=nodata, 196 | layers=layers, 197 | geojson=json.load(geojson) if geojson else None, 198 | ) 199 | if not server_only: 200 | click.echo(f"Viewer started at {application.template_url}", err=True) 201 | click.launch(application.template_url) 202 | 203 | application.start() 204 | -------------------------------------------------------------------------------- /rio_viz/templates/assets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rio-viz for Asset Reader (STAC) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 186 | 187 | 188 | 298 | 299 |
300 |
301 |
302 |
303 | 304 |
305 |
306 |
307 |
308 |
309 | 310 | 1085 | 1086 | 1087 | -------------------------------------------------------------------------------- /rio_viz/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rio Viz 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 182 | 183 | 184 | 307 | 308 |
309 |
310 |
311 |
312 | 313 |
314 |
315 |
316 |
317 |
318 | 319 | 1204 | 1205 | 1206 | -------------------------------------------------------------------------------- /rio_viz/templates/map.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rio Viz 6 | 7 | 8 | 9 | 10 | 32 | 33 | 34 | 35 |
36 |
37 |
38 | 39 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /rio_viz/templates/wmts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | "{{ title }}" 4 | OGC WMTS 5 | 1.0.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | RESTful 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | RESTful 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{ title }} 38 | {{ layer_name }} 39 | {{ title }} 40 | 41 | {{ bounds[0] }} {{ bounds[1] }} 42 | {{ bounds[2] }} {{ bounds[3] }} 43 | 44 | 47 | {{ media_type }} 48 | 49 | GoogleMapsCompatible 50 | 51 | 52 | 53 | 54 | GoogleMapsCompatible 55 | urn:ogc:def:crs:EPSG::3857 56 | {% for item in tileMatrix %} 57 | {{ item | safe }} 58 | {% endfor %} 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/fixtures/cog.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/cog.tif -------------------------------------------------------------------------------- /tests/fixtures/cogb1.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/cogb1.tif -------------------------------------------------------------------------------- /tests/fixtures/cogb2.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/cogb2.tif -------------------------------------------------------------------------------- /tests/fixtures/cogb3.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/cogb3.tif -------------------------------------------------------------------------------- /tests/fixtures/mosaic_cog1.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/mosaic_cog1.tif -------------------------------------------------------------------------------- /tests/fixtures/mosaic_cog2.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/mosaic_cog2.tif -------------------------------------------------------------------------------- /tests/fixtures/noncog.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/rio-viz/c8676343ae2f84f17322372da481f5194ad3f016/tests/fixtures/noncog.tif -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | """tests rio_viz.server.""" 2 | 3 | import json 4 | import os 5 | 6 | import pytest 7 | from rio_tiler.io import COGReader 8 | from starlette.testclient import TestClient 9 | 10 | from rio_viz.app import viz 11 | from rio_viz.io.mosaic import MosaicReader 12 | from rio_viz.io.reader import MultiFilesAssetsReader, MultiFilesBandsReader 13 | 14 | cog_path = os.path.join(os.path.dirname(__file__), "fixtures", "cog.tif") 15 | cogb1b2b3_path = os.path.join(os.path.dirname(__file__), "fixtures", "cogb{1,2,3}.tif") 16 | cog_mosaic_path = os.path.join( 17 | os.path.dirname(__file__), "fixtures", "mosaic_cog{1,2}.tif" 18 | ) 19 | 20 | 21 | def test_viz(): 22 | """Should work as expected (create TileServer object).""" 23 | src_path = cog_path 24 | dataset_reader = COGReader 25 | 26 | app = viz(src_path, reader=dataset_reader) 27 | 28 | assert app.port == 8080 29 | assert app.endpoint == "http://127.0.0.1:8080" 30 | assert app.template_url == "http://127.0.0.1:8080/index.html" 31 | 32 | client = TestClient(app.app) 33 | response = client.get("/") 34 | assert response.status_code == 200 35 | assert response.headers["cache-control"] == "no-cache" 36 | 37 | response = client.get("/index.html") 38 | assert response.status_code == 200 39 | assert response.headers["cache-control"] == "no-cache" 40 | 41 | response = client.get("/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10") 42 | assert response.status_code == 200 43 | assert response.headers["content-type"] == "image/png" 44 | assert response.headers["cache-control"] == "no-cache" 45 | 46 | response = client.get( 47 | "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&bidx=1&color_formula=Gamma R 3" 48 | ) 49 | assert response.status_code == 200 50 | assert response.headers["content-type"] == "image/png" 51 | 52 | response = client.get( 53 | "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&bidx=1&bidx=1&bidx=1" 54 | ) 55 | assert response.status_code == 200 56 | assert response.headers["content-type"] == "image/png" 57 | 58 | response = client.get( 59 | "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&colormap_name=cfastie" 60 | ) 61 | assert response.status_code == 200 62 | assert response.headers["content-type"] == "image/png" 63 | 64 | response = client.get( 65 | "/tiles/WebMercatorQuad/7/64/43?rescale=1,10&colormap_name=cfastie" 66 | ) 67 | assert response.status_code == 200 68 | assert response.headers["content-type"] == "image/png" 69 | 70 | response = client.get("/tiles/WebMercatorQuad/18/8624/119094.png") 71 | assert response.status_code == 404 72 | 73 | response = client.get("/tiles/WebMercatorQuad/18/8624/119094.pbf") 74 | assert response.status_code == 404 75 | 76 | response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf") 77 | assert response.status_code == 500 78 | assert not response.headers.get("cache-control") 79 | 80 | response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf?feature_type=polygon") 81 | assert response.status_code == 200 82 | assert response.headers["content-type"] == "application/x-protobuf" 83 | 84 | response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf?feature_type=point") 85 | assert response.status_code == 200 86 | assert response.headers["content-type"] == "application/x-protobuf" 87 | 88 | response = client.get("/preview?rescale=1,10&colormap_name=cfastie") 89 | assert response.status_code == 200 90 | assert response.headers["content-type"] == "image/jpeg" 91 | assert response.headers["cache-control"] == "no-cache" 92 | 93 | response = client.get("/preview.png?rescale=1,10&colormap_name=cfastie") 94 | assert response.status_code == 200 95 | assert response.headers["content-type"] == "image/png" 96 | 97 | response = client.get( 98 | "/bbox/-2.00,48.5,-1,49.5.png?rescale=1,10&colormap_name=cfastie" 99 | ) 100 | assert response.status_code == 200 101 | assert response.headers["content-type"] == "image/png" 102 | 103 | response = client.get( 104 | "/bbox/-2.00,48.5,-1,49.5/100x100.jpeg?&rescale=1,10&colormap_name=cfastie" 105 | ) 106 | assert response.status_code == 200 107 | assert response.headers["content-type"] == "image/jpeg" 108 | 109 | response = client.get("/info") 110 | assert response.status_code == 200 111 | assert response.headers["content-type"] == "application/json" 112 | 113 | response = client.get("/statistics") 114 | assert response.status_code == 200 115 | assert response.headers["content-type"] == "application/json" 116 | 117 | response = client.get("/tilejson.json?tile_format=png") 118 | assert response.status_code == 200 119 | assert response.headers["content-type"] == "application/json" 120 | r = response.json() 121 | assert r["bounds"] 122 | assert r["center"] 123 | assert r["minzoom"] == 7 124 | assert r["maxzoom"] == 9 125 | assert r["tiles"][0].endswith("png") 126 | 127 | response = client.get("/tilejson.json?tile_format=pbf") 128 | assert response.status_code == 200 129 | assert response.headers["content-type"] == "application/json" 130 | r = response.json() 131 | assert r["tiles"][0].endswith("pbf") 132 | 133 | response = client.get("/tilejson.json?tile_format=pbf&feature_type=polygon") 134 | assert response.status_code == 200 135 | assert response.headers["content-type"] == "application/json" 136 | r = response.json() 137 | assert r["tiles"][0].endswith("pbf?feature_type=polygon") 138 | 139 | response = client.get("/point?coordinates=-2,48") 140 | assert response.status_code == 200 141 | assert response.headers["content-type"] == "application/json" 142 | assert response.json() == { 143 | "band_names": ["b1"], 144 | "coordinates": [-2.0, 48.0], 145 | "values": [110], 146 | } 147 | 148 | feat = json.dumps( 149 | { 150 | "type": "Feature", 151 | "properties": {}, 152 | "geometry": { 153 | "type": "Polygon", 154 | "coordinates": [ 155 | [ 156 | [-1.8511962890624998, 49.296471602658066], 157 | [-2.5213623046875, 48.56388521347092], 158 | [-2.1258544921875, 48.213692646648035], 159 | [-1.4556884765625, 48.356249029540734], 160 | [-1.1590576171875, 48.469279317167164], 161 | [-0.8184814453125, 49.46455408928758], 162 | [-1.4666748046875, 49.55728898983402], 163 | [-1.64794921875, 49.50380954152213], 164 | [-1.8511962890624998, 49.296471602658066], 165 | ] 166 | ], 167 | }, 168 | } 169 | ) 170 | 171 | response = client.post("/feature", data=feat) 172 | assert response.status_code == 200 173 | assert response.headers["content-type"] == "image/png" 174 | 175 | response = client.post("/feature.jpeg", data=feat) 176 | assert response.status_code == 200 177 | assert response.headers["content-type"] == "image/jpeg" 178 | 179 | response = client.post("/feature/100x100.jpeg", data=feat) 180 | assert response.status_code == 200 181 | assert response.headers["content-type"] == "image/jpeg" 182 | 183 | response = client.post( 184 | "/feature.jpeg", 185 | params={"bidx": 1, "rescale": "1,10", "colormap_name": "cfastie"}, 186 | data=feat, 187 | ) 188 | assert response.status_code == 200 189 | assert response.headers["content-type"] == "image/jpeg" 190 | 191 | 192 | def test_viz_custom(): 193 | """Should work as expected (create TileServer object).""" 194 | src_path = cog_path 195 | app = viz(src_path, reader=COGReader, host="0.0.0.0", port=5050) 196 | assert app.port == 5050 197 | assert app.endpoint == "http://0.0.0.0:5050" 198 | 199 | 200 | def test_viz_multibands(): 201 | """Should work as expected (create TileServer object).""" 202 | dataset_reader = MultiFilesBandsReader 203 | 204 | # Use default bands from the reader 205 | app = viz(cogb1b2b3_path, reader=dataset_reader) 206 | assert app.port == 8080 207 | assert app.endpoint == "http://127.0.0.1:8080" 208 | client = TestClient(app.app) 209 | 210 | response = client.get("/info") 211 | assert response.status_code == 200 212 | assert response.headers["content-type"] == "application/json" 213 | assert response.json()["band_descriptions"] == [ 214 | ["b1", ""], 215 | ["b2", ""], 216 | ["b3", ""], 217 | ] 218 | 219 | response = client.get("/info?bands=b1&bands=b2") 220 | assert response.status_code == 200 221 | assert response.headers["content-type"] == "application/json" 222 | assert response.json()["band_descriptions"] == [ 223 | ["b1", ""], 224 | ["b2", ""], 225 | ] 226 | 227 | response = client.get("/statistics") 228 | assert response.status_code == 200 229 | assert response.headers["content-type"] == "application/json" 230 | assert ["b1", "b2", "b3"] == list(response.json()) 231 | 232 | response = client.get("/statistics?bands=b1&bands=b2") 233 | assert response.status_code == 200 234 | assert response.headers["content-type"] == "application/json" 235 | assert ["b1", "b2"] == list(response.json()) 236 | 237 | response = client.get("/point?coordinates=-2.0,48.0") 238 | assert response.status_code == 200 239 | assert response.headers["content-type"] == "application/json" 240 | assert len(response.json()["values"]) == 3 241 | 242 | response = client.get("/point?coordinates=-2.0,48.0&bands=b1&bands=b2") 243 | assert response.status_code == 200 244 | assert response.headers["content-type"] == "application/json" 245 | assert len(response.json()["values"]) == 2 246 | 247 | # Set default bands (other bands might still be available within the reader) 248 | app = viz(cogb1b2b3_path, reader=dataset_reader, layers=["b1"]) 249 | assert app.port == 8080 250 | assert app.endpoint == "http://127.0.0.1:8080" 251 | client = TestClient(app.app) 252 | 253 | response = client.get("/info") 254 | assert response.status_code == 200 255 | assert response.headers["content-type"] == "application/json" 256 | assert response.json()["band_descriptions"] == [ 257 | ["b1", ""], 258 | ] 259 | 260 | response = client.get("/info?bands=b1&bands=b2") 261 | assert response.status_code == 200 262 | assert response.headers["content-type"] == "application/json" 263 | assert response.json()["band_descriptions"] == [ 264 | ["b1", ""], 265 | ["b2", ""], 266 | ] 267 | 268 | response = client.get("/statistics") 269 | assert response.status_code == 200 270 | assert response.headers["content-type"] == "application/json" 271 | assert ["b1"] == list(response.json()) 272 | 273 | response = client.get("/statistics?bands=b1&bands=b2") 274 | assert response.status_code == 200 275 | assert response.headers["content-type"] == "application/json" 276 | assert ["b1", "b2"] == list(response.json()) 277 | 278 | response = client.get("/point?coordinates=-2.0,48.0") 279 | assert response.status_code == 200 280 | assert response.headers["content-type"] == "application/json" 281 | assert len(response.json()["values"]) == 1 282 | 283 | response = client.get("/point?coordinates=-2.0,48.0&bands=b1&bands=b2") 284 | assert response.status_code == 200 285 | assert response.headers["content-type"] == "application/json" 286 | assert len(response.json()["values"]) == 2 287 | 288 | 289 | def test_viz_multiassets(): 290 | """Should work as expected (create TileServer object).""" 291 | dataset_reader = MultiFilesAssetsReader 292 | # Use default bands from the reader 293 | app = viz(cogb1b2b3_path, reader=dataset_reader) 294 | assert app.port == 8080 295 | assert app.endpoint == "http://127.0.0.1:8080" 296 | client = TestClient(app.app) 297 | 298 | response = client.get("/info") 299 | assert response.status_code == 200 300 | assert response.headers["content-type"] == "application/json" 301 | assert ["asset1", "asset2", "asset3"] == list(response.json()) 302 | assert response.json()["asset1"]["band_descriptions"] 303 | 304 | response = client.get("/info?assets=asset1&assets=asset2") 305 | assert response.status_code == 200 306 | assert response.headers["content-type"] == "application/json" 307 | assert ["asset1", "asset2"] == list(response.json()) 308 | 309 | response = client.get("/statistics") 310 | assert response.status_code == 200 311 | assert response.headers["content-type"] == "application/json" 312 | assert ["asset1", "asset2", "asset3"] == list(response.json()) 313 | 314 | response = client.get("/statistics?assets=asset1&assets=asset2") 315 | assert response.status_code == 200 316 | assert response.headers["content-type"] == "application/json" 317 | assert ["asset1", "asset2"] == list(response.json()) 318 | 319 | response = client.get("/point?coordinates=-2.0,48.0") 320 | assert response.status_code == 200 321 | assert response.headers["content-type"] == "application/json" 322 | assert len(response.json()["values"]) == 3 323 | assert response.json()["band_names"] == ["asset1_b1", "asset2_b1", "asset3_b1"] 324 | 325 | response = client.get("/point?coordinates=-2.0,48.0&assets=asset1&assets=asset2") 326 | assert response.status_code == 200 327 | assert response.headers["content-type"] == "application/json" 328 | assert len(response.json()["values"]) == 2 329 | assert response.json()["band_names"] == ["asset1_b1", "asset2_b1"] 330 | 331 | # Set default bands (other bands might still be available within the reader) 332 | app = viz(cogb1b2b3_path, reader=dataset_reader, layers=["asset1"]) 333 | assert app.port == 8080 334 | assert app.endpoint == "http://127.0.0.1:8080" 335 | client = TestClient(app.app) 336 | 337 | response = client.get("/info") 338 | assert response.status_code == 200 339 | assert response.headers["content-type"] == "application/json" 340 | assert ["asset1"] == list(response.json()) 341 | 342 | response = client.get("/info?assets=asset1&assets=asset2") 343 | assert response.status_code == 200 344 | assert response.headers["content-type"] == "application/json" 345 | assert ["asset1", "asset2"] == list(response.json()) 346 | 347 | response = client.get("/statistics") 348 | assert response.status_code == 200 349 | assert response.headers["content-type"] == "application/json" 350 | assert ["asset1"] == list(response.json()) 351 | 352 | response = client.get("/statistics?assets=asset1&assets=asset2") 353 | assert response.status_code == 200 354 | assert response.headers["content-type"] == "application/json" 355 | assert ["asset1", "asset2"] == list(response.json()) 356 | 357 | response = client.get("/point?coordinates=-2.0,48.0") 358 | assert response.status_code == 200 359 | assert response.headers["content-type"] == "application/json" 360 | assert len(response.json()["values"]) == 1 361 | 362 | response = client.get("/point?coordinates=-2.0,48.0&assets=asset1&assets=asset2") 363 | assert response.status_code == 200 364 | assert response.headers["content-type"] == "application/json" 365 | assert len(response.json()["values"]) == 2 366 | 367 | 368 | def test_viz_mosaic(): 369 | """Should work as expected (create TileServer object).""" 370 | src_path = cog_mosaic_path 371 | dataset_reader = MosaicReader 372 | 373 | app = viz(src_path, reader=dataset_reader) 374 | 375 | assert app.port == 8080 376 | assert app.endpoint == "http://127.0.0.1:8080" 377 | assert app.template_url == "http://127.0.0.1:8080/index.html" 378 | 379 | client = TestClient(app.app) 380 | response = client.get("/") 381 | assert response.status_code == 200 382 | assert response.headers["cache-control"] == "no-cache" 383 | 384 | response = client.get("/index.html") 385 | assert response.status_code == 200 386 | assert response.headers["cache-control"] == "no-cache" 387 | 388 | response = client.get("/info") 389 | assert response.status_code == 200 390 | assert response.headers["content-type"] == "application/json" 391 | 392 | response = client.get("/statistics") 393 | assert response.status_code == 200 394 | assert response.headers["content-type"] == "application/json" 395 | 396 | response = client.get("/tiles/WebMercatorQuad/8/75/91?rescale=1,10") 397 | assert response.status_code == 200 398 | assert response.headers["content-type"] == "image/png" 399 | assert response.headers["cache-control"] == "no-cache" 400 | 401 | with pytest.raises(NotImplementedError): 402 | client.get("/preview") 403 | 404 | with pytest.raises(NotImplementedError): 405 | client.get("/bbox/-2.00,48.5,-1,49.5.png") 406 | 407 | # Point is Outside COGs bounds 408 | response = client.get("/point?coordinates=-2,48") 409 | assert response.status_code == 500 410 | assert "Method returned an empty array" in response.text # InvalidPointDataError 411 | 412 | response = client.get("/point?coordinates=-72.63567185076337,46.10493842126715") 413 | assert response.status_code == 200 414 | assert response.headers["content-type"] == "application/json" 415 | assert response.json() == { 416 | "band_names": ["b1", "b2", "b3"], 417 | "coordinates": [-72.63567185076337, 46.10493842126715], 418 | "values": [12453, 11437, 11360], 419 | } 420 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """tests rio_viz.server.""" 2 | 3 | import os 4 | from unittest.mock import patch 5 | 6 | from click.testing import CliRunner 7 | 8 | from rio_viz.scripts.cli import viz 9 | 10 | cog_path = os.path.join(os.path.dirname(__file__), "fixtures", "cog.tif") 11 | noncog_path = os.path.join(os.path.dirname(__file__), "fixtures", "noncog.tif") 12 | 13 | 14 | @patch("rio_viz.app.viz") 15 | @patch("click.launch") 16 | def test_viz_valid(launch, app): 17 | """Should work as expected.""" 18 | app.return_value.get_template_url.return_value = "http://127.0.0.1:8080/index.html" 19 | app.return_value.start.return_value = True 20 | 21 | launch.return_value = True 22 | 23 | runner = CliRunner() 24 | result = runner.invoke(viz, [cog_path]) 25 | app.assert_called_once() 26 | assert not result.exception 27 | assert result.exit_code == 0 28 | 29 | 30 | @patch("rio_viz.app.viz") 31 | @patch("click.launch") 32 | def test_viz_invalidCog(launch, app): 33 | """Should work as expected.""" 34 | app.return_value.start.return_value = True 35 | launch.return_value = True 36 | 37 | runner = CliRunner() 38 | result = runner.invoke(viz, [noncog_path]) 39 | assert app.call_args[0] is not noncog_path 40 | app.assert_called_once() 41 | assert not result.exception 42 | assert result.exit_code == 0 43 | -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | """test rio-viz enums.""" 2 | 3 | import pytest 4 | 5 | from rio_viz.resources.enums import RasterFormat, VectorTileFormat 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "value,driver,mimetype", 10 | [ 11 | ("png", "PNG", "image/png"), 12 | ("npy", "NPY", "application/x-binary"), 13 | ("tif", "GTiff", "image/tiff; application=geotiff"), 14 | ("jpeg", "JPEG", "image/jpeg"), 15 | ("jp2", "JP2OpenJPEG", "image/jp2"), 16 | ("webp", "WEBP", "image/webp"), 17 | ("pngraw", "PNG", "image/png"), 18 | ], 19 | ) 20 | def test_rasterformat(value, driver, mimetype): 21 | """Test driver and mimetype values.""" 22 | assert RasterFormat[value].driver == driver 23 | assert RasterFormat[value].mediatype == mimetype 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "value,driver,mimetype", 28 | [("pbf", "", "application/x-protobuf"), ("mvt", "", "application/x-protobuf")], 29 | ) 30 | def test_vectorformat(value, driver, mimetype): 31 | """Test driver and mimetype values.""" 32 | assert VectorTileFormat[value].driver == driver 33 | assert VectorTileFormat[value].mediatype == mimetype 34 | --------------------------------------------------------------------------------