├── .dockerignore ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── doc.yml │ ├── ecr.yml │ ├── lint.yml │ ├── package.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Procfile ├── README.md ├── codecov.yml ├── doc ├── Makefile ├── make.bat └── source │ ├── _static │ ├── copybutton.css │ ├── fontawesome │ │ ├── LICENSE.txt │ │ ├── css │ │ │ └── all.css │ │ └── webfonts │ │ │ ├── fa-brands-400.eot │ │ │ ├── fa-brands-400.svg │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-brands-400.woff │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.eot │ │ │ ├── fa-regular-400.svg │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff │ │ │ ├── fa-regular-400.woff2 │ │ │ ├── fa-solid-900.eot │ │ │ ├── fa-solid-900.svg │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff │ │ │ └── fa-solid-900.woff2 │ └── no_search_highlight.css │ ├── _templates │ └── layout.html │ ├── api │ └── index.rst │ ├── conf.py │ ├── index.rst │ ├── installation │ ├── docker.rst │ ├── index.rst │ └── remote-jupyter.rst │ └── user-guide │ ├── bokeh.rst │ ├── compare.rst │ ├── example-data.rst │ ├── hillshade.rst │ ├── in-memory.rst │ ├── index.rst │ ├── ipyleaflet_deep_zoom.rst │ ├── rasterio.rst │ ├── remote-cog.rst │ ├── rgb.rst │ ├── validate_cog.rst │ └── web-app.rst ├── example.ipynb ├── flask.env ├── ignore_words.txt ├── imgs ├── bahamas-tiles-wide.png ├── cesium-viewer.png ├── elevation.png ├── folium.png ├── golden-dem.png ├── golden-roi.png ├── hillshade.png ├── hillshade_compare.png ├── ipyleaflet-draw-roi.png ├── ipyleaflet-multi-bands.png ├── ipyleaflet.gif ├── ipyleaflet.png ├── kafadar.png ├── oam-tiles.jpg ├── oam-tiles.png ├── pip-gdal.jpg ├── presidio.png ├── tile-diagram.gif ├── tile-diagram.png ├── vsi-raster.png ├── webviewer-roi.gif └── webviewer.gif ├── jupyter.Dockerfile ├── localtileserver ├── __init__.py ├── __main__.py ├── _version.py ├── client.py ├── configure.py ├── examples.py ├── helpers.py ├── manager.py ├── report.py ├── tiler │ ├── __init__.py │ ├── data │ │ ├── .gitignore │ │ ├── __init__.py │ │ ├── aws_elevation_tiles_prod.xml │ │ ├── bahamas_rgb.tif │ │ ├── co_elevation_roi.tif │ │ ├── frmt_wms_arcgis_mapserver_tms.xml │ │ ├── frmt_wms_bluemarble_s3_tms.xml │ │ ├── frmt_wms_virtualearth.xml │ │ ├── landsat.tif │ │ ├── landsat7.tif │ │ └── presidio.wkb │ ├── handler.py │ ├── palettes.py │ └── utilities.py ├── utilities.py ├── validate.py ├── web │ ├── __init__.py │ ├── application.py │ ├── blueprint.py │ ├── rest.py │ ├── sentry.py │ ├── static │ │ ├── js │ │ │ └── cesium.js │ │ └── styles │ │ │ ├── cesium.css │ │ │ ├── snackbar.css │ │ │ └── style.css │ ├── templates │ │ └── tileserver │ │ │ ├── 404file.html │ │ │ ├── _include │ │ │ ├── cesium.html │ │ │ ├── examples.html │ │ │ └── palettes.html │ │ │ ├── base.html │ │ │ ├── baseTileViewer.html │ │ │ ├── cesiumSplitViewer.html │ │ │ ├── cesiumViewer.html │ │ │ └── splitForm.html │ ├── urls.py │ ├── utils.py │ ├── views.py │ └── wsgi.py └── widgets.py ├── pyproject.toml ├── requirements.txt ├── requirements_doc.txt ├── requirements_jupyter.txt ├── setup.py └── tests ├── .gitignore ├── __init__.py ├── baseline ├── test_custom_colormap[colormap0-None].png ├── test_custom_colormap[colormap1-2].png ├── test_landsat7_nodata[0].png ├── test_landsat7_nodata[255].png ├── test_landsat7_nodata[None].png ├── test_thumbnail.png ├── test_thumbnail_colormap[inferno-1].png ├── test_thumbnail_colormap[viridis-1].png ├── test_thumbnail_colormap[viridis-None].png ├── test_thumbnail_nodata[0].png ├── test_thumbnail_nodata[255].png ├── test_thumbnail_nodata[None].png ├── test_tile.png ├── test_tile_colormap[None-None].png ├── test_tile_colormap[inferno-1].png ├── test_tile_colormap[viridis-1].png ├── test_tile_colormap[viridis-None].png ├── test_tile_indexes[1].png ├── test_tile_indexes[2].png ├── test_tile_indexes[3].png ├── test_tile_indexes[indexes0].png ├── test_tile_indexes[indexes1].png ├── test_tile_nodata[0].png ├── test_tile_nodata[255].png ├── test_tile_nodata[None].png ├── test_tile_vmax[100].png ├── test_tile_vmax[vmax1].png ├── test_tile_vmin[100].png ├── test_tile_vmin[vmin1].png └── test_tile_vmin_vmax.png ├── conftest.py ├── test_app.py ├── test_client.py ├── test_examples.py ├── test_folium.py ├── test_helpers.py ├── test_leaflet.py ├── test_rendering.py ├── test_utilities.py ├── test_vsi.py └── utilities.py /.dockerignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | *.egg-info 4 | .pytest_cache 5 | *.pyc 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__,build,dist,doc/build 3 | ignore = 4 | # whitespace before ':' 5 | E203, 6 | # line break before binary operator 7 | W503, 8 | # line length too long 9 | E501, 10 | # do not assign a lambda expression, use a def 11 | E731, 12 | # too many leading '#' for block comment 13 | E266, 14 | # ambiguous variable name 15 | E741, 16 | # module level import not at top of file 17 | E402, 18 | # Quotes (temporary) 19 | Q0, 20 | # bare excepts (temporary) 21 | B001, E722 22 | # we already check black 23 | BLK100 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "monthly" 20 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | name: Build Documentation 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | LOCALTILESERVER_BUILDING_DOCS: true 11 | 12 | jobs: 13 | build-doc: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.9 21 | # Install everything else 22 | - name: Install other dependencies 23 | run: | 24 | pip install -r requirements_jupyter.txt -r requirements_doc.txt 25 | - name: Install localtileserver 26 | run: pip install -e . 27 | - name: Scooby Report 28 | run: python -c "import localtileserver;print(localtileserver.Report())" 29 | - name: Build Documentation 30 | working-directory: doc 31 | run: make html 32 | - name: Stash build 33 | uses: actions/upload-artifact@v3 34 | with: 35 | name: doc-build 36 | path: doc/build 37 | 38 | deploy: 39 | name: Publish Documentation 40 | runs-on: ubuntu-latest 41 | needs: build-doc 42 | if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/download-artifact@v3 46 | with: 47 | name: doc-build 48 | path: doc/build 49 | - name: Deploy to GH Pages 50 | uses: peaceiris/actions-gh-pages@v3 51 | with: 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | publish_dir: doc/build/html 54 | cname: localtileserver.banesullivan.com 55 | -------------------------------------------------------------------------------- /.github/workflows/ecr.yml: -------------------------------------------------------------------------------- 1 | name: ECR Package 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - "*" 7 | branches: 8 | - main 9 | 10 | env: 11 | IMAGE_NAME: localtileserver 12 | 13 | jobs: 14 | build-and-publish: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | - name: Configure AWS credentials 21 | uses: aws-actions/configure-aws-credentials@v4 22 | with: 23 | aws-region: us-west-2 24 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 25 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 26 | - name: Login to Amazon ECR 27 | id: login-ecr 28 | uses: aws-actions/amazon-ecr-login@v2 29 | - name: Extract metadata for the Docker image 30 | id: meta 31 | uses: docker/metadata-action@v5 32 | with: 33 | images: ${{ steps.login-ecr.outputs.registry }}/${{ env.IMAGE_NAME }} 34 | tags: | 35 | # set latest tag for main branch 36 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} 37 | - name: Build and push the Docker image 38 | uses: docker/build-push-action@v6 39 | with: 40 | context: . 41 | file: Dockerfile 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} 45 | cache-from: type=gha 46 | cache-to: type=gha,mode=max 47 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | style: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.8" 17 | - name: Install Style dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pre-commit 21 | - name: Run linting 22 | run: make lint 23 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Docker Package 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - "*" 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build-and-publish: 17 | runs-on: ubuntu-latest 18 | if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository ) 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Log into the Container registry 22 | uses: docker/login-action@v3 23 | with: 24 | registry: ${{ env.REGISTRY }} 25 | username: token 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Extract metadata for the Docker image 28 | id: meta 29 | uses: docker/metadata-action@v5 30 | with: 31 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 32 | - name: Build and push the Docker image 33 | uses: docker/build-push-action@v6 34 | with: 35 | context: . 36 | file: Dockerfile 37 | push: ${{ github.actor != 'dependabot[bot]' }} 38 | tags: ${{ steps.meta.outputs.tags }} 39 | labels: ${{ steps.meta.outputs.labels }} 40 | - name: Extract metadata for the Jupyter Docker image 41 | id: meta_jupyter 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-jupyter 45 | - name: Build and push the Jupyter Docker image 46 | uses: docker/build-push-action@v6 47 | with: 48 | context: . 49 | file: jupyter.Dockerfile 50 | push: ${{ github.actor != 'dependabot[bot]' }} 51 | tags: ${{ steps.meta_jupyter.outputs.tags }} 52 | labels: ${{ steps.meta_jupyter.outputs.labels }} 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Package Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Python 12 | uses: actions/setup-python@v5 13 | with: 14 | python-version: "3.11" 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install setuptools wheel twine 19 | - name: Build and Publish to PyPI 20 | env: 21 | TWINE_USERNAME: __token__ 22 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 23 | run: | 24 | python setup.py sdist bdist_wheel 25 | twine upload --skip-existing dist/* 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.8, 3.9, "3.10", "3.11"] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | # Install everything else 25 | - name: Install other dependencies 26 | run: | 27 | pip install -r requirements_jupyter.txt 28 | - name: Install localtileserver 29 | run: pip install -e . 30 | - name: Scooby Report 31 | run: python -c "import localtileserver;print(localtileserver.Report())" 32 | - name: Run Tests 33 | run: | 34 | pytest -v --cov=localtileserver 35 | coverage xml -o coverage.xml 36 | # - name: Run Doc Tests 37 | # run: | 38 | # make doctest 39 | - name: Stash coverage 40 | uses: actions/upload-artifact@v3 41 | with: 42 | name: coverage.xml 43 | path: coverage.xml 44 | - uses: codecov/codecov-action@v5 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | files: coverage.xml 48 | verbose: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | **/node_modules/ 3 | *.pyc 4 | *.egg-info 5 | dist/ 6 | build/ 7 | **/.DS_Store 8 | .coverage 9 | .coverage* 10 | gdalwmscache 11 | htmlcov 12 | 13 | electron 14 | notebooks 15 | .pytest_cache 16 | .ipynb_checkpoints 17 | bin/ 18 | Untitled*.ipynb 19 | *.aux.xml 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.12.1 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/pycqa/isort 8 | rev: 5.13.2 9 | hooks: 10 | - id: isort 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.5.0 14 | hooks: 15 | - id: check-merge-conflict 16 | - id: debug-statements 17 | - id: requirements-txt-fixer 18 | - id: trailing-whitespace 19 | - id: check-docstring-first 20 | - id: end-of-file-fixer 21 | - id: mixed-line-ending 22 | - id: check-toml 23 | - id: check-yaml 24 | 25 | - repo: https://github.com/python-jsonschema/check-jsonschema 26 | rev: 0.27.3 27 | hooks: 28 | - id: check-github-workflows 29 | 30 | 31 | - repo: https://github.com/PyCQA/flake8 32 | rev: 3.9.2 33 | hooks: 34 | - id: flake8 35 | additional_dependencies: [ 36 | "flake8-black==0.3.6", 37 | "flake8-isort==6.0.0", 38 | "flake8-quotes==3.3.2", 39 | ] 40 | 41 | 42 | # - repo: https://github.com/codespell-project/codespell 43 | # rev: v2.2.6 44 | # hooks: 45 | # - id: codespell 46 | # args: [ 47 | # "doc examples examples_trame pyvista tests", 48 | # "*.py *.rst *.md", 49 | # ] 50 | # additional_dependencies: [ 51 | # "tomli" 52 | # ] 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.3-slim 2 | LABEL maintainer="Bane Sullivan" 3 | LABEL repo="https://github.com/banesullivan/localtileserver" 4 | 5 | COPY requirements.txt /build-context/ 6 | WORKDIR /build-context 7 | 8 | RUN python -m pip install --upgrade pip 9 | RUN pip install -r requirements.txt 10 | 11 | COPY setup.py /build-context/ 12 | COPY MANIFEST.in /build-context/ 13 | COPY localtileserver/ /build-context/localtileserver/ 14 | RUN python setup.py bdist_wheel 15 | RUN pip install dist/localtileserver*.whl 16 | 17 | ENTRYPOINT ["gunicorn", "--bind=0.0.0.0:8000", "localtileserver.web.wsgi:app"] 18 | # docker run --rm -it -p 8000:8000 localtileserver 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Bane Sullivan 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft localtileserver/web/static 2 | graft localtileserver/web/templates 3 | global-exclude *.pyc 4 | graft localtileserver/tiler/data 5 | exclude localtileserver/tiler/data/*.aux.xml 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Simple makefile to simplify repetitive build env management tasks under posix 2 | 3 | CODESPELL_DIRS ?= ./ 4 | CODESPELL_SKIP ?= "*.pyc,*.txt,*.gif,*.svg,*.css,*.png,*.jpg,*.ply,*.vtk,*.vti,*.js,*.html,*.doctree,*.ttf,*.woff,*.woff2,*.eot,*.mp4,*.inv,*.pickle,*.ipynb,flycheck*,./.git/*,./.hypothesis/*,*.yml,./doc/_build/*,./doc/build/*,./doc/images/*,./dist/*,*~,.hypothesis*,./doc/examples/*,*.mypy_cache/*,*cover,./tests/tinypages/_build/*,*/_autosummary/*" 5 | CODESPELL_IGNORE ?= "ignore_words.txt" 6 | 7 | 8 | stylecheck: codespell lint 9 | 10 | codespell: 11 | @echo "Running codespell" 12 | @codespell $(CODESPELL_DIRS) -S $(CODESPELL_SKIP) -I $(CODESPELL_IGNORE) 13 | 14 | pydocstyle: 15 | @echo "Running pydocstyle" 16 | @pydocstyle localtileserver --match='(?!coverage).*.py' 17 | 18 | doctest: 19 | @echo "Running module doctesting" 20 | pytest -v --doctest-modules localtileserver 21 | 22 | lint: 23 | @echo "Linting with flake8" 24 | pre-commit run --all-files 25 | 26 | format: 27 | @echo "Formatting" 28 | pre-commit run --all-files 29 | 30 | clean-test-images: 31 | @echo "Cleaning test images" 32 | rm -rf tests/baseline/ 33 | rm -rf tests/generated/ 34 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn localtileserver.web.wsgi:app 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![tile-diagram](https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/oam-tiles.jpg) 2 | 3 | # 🌐 Local Tile Server for Geospatial Rasters 4 | 5 | [![codecov](https://codecov.io/gh/banesullivan/localtileserver/branch/main/graph/badge.svg?token=S0HQ64FW8G)](https://codecov.io/gh/banesullivan/localtileserver) 6 | [![PyPI](https://img.shields.io/pypi/v/localtileserver.svg?logo=python&logoColor=white)](https://pypi.org/project/localtileserver/) 7 | [![conda](https://img.shields.io/conda/vn/conda-forge/localtileserver.svg?logo=conda-forge&logoColor=white)](https://anaconda.org/conda-forge/localtileserver) 8 | 9 | *Need to visualize a rather large (gigabytes+) raster?* **This is for you.** 10 | 11 | A Python package for serving tiles from large raster files in 12 | the [Slippy Maps standard](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) 13 | (i.e., `/zoom/x/y.png`) for visualization in Jupyter with `ipyleaflet` or `folium`. 14 | 15 | Launch a [demo](https://github.com/banesullivan/localtileserver-demo) on MyBinder [![MyBinder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/banesullivan/localtileserver-demo/HEAD) 16 | 17 | Documentation: https://localtileserver.banesullivan.com/ 18 | 19 | Built on [rio-tiler](https://github.com/cogeotiff/rio-tiler) 20 | 21 | 22 | ## 🌟 Highlights 23 | 24 | - Launch a tile server for large geospatial images 25 | - View local or remote* raster files with `ipyleaflet` or `folium` in Jupyter 26 | - View rasters with CesiumJS with the built-in web application 27 | 28 | **remote raster files should be pre-tiled Cloud Optimized GeoTiffs* 29 | 30 | ## 🚀 Usage 31 | 32 | Usage details and examples can be found in the documentation: https://localtileserver.banesullivan.com/ 33 | 34 | The following is a minimal example to visualize a local raster file with 35 | `ipyleaflet`: 36 | 37 | ```py 38 | from localtileserver import get_leaflet_tile_layer, TileClient 39 | from ipyleaflet import Map 40 | 41 | # First, create a tile server from local raster file 42 | client = TileClient('path/to/geo.tif') 43 | 44 | # Create ipyleaflet tile layer from that server 45 | t = get_leaflet_tile_layer(client) 46 | 47 | m = Map(center=client.center(), zoom=client.default_zoom) 48 | m.add(t) 49 | m 50 | ``` 51 | 52 | ![ipyleaflet](https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/ipyleaflet.png) 53 | 54 | ## ℹ️ Overview 55 | 56 | The `TileClient` class can be used to to launch a tile server in a background 57 | thread which will serve raster imagery to a viewer (usually `ipyleaflet` or 58 | `folium` in Jupyter notebooks). 59 | 60 | This tile server can efficiently deliver varying resolutions of your 61 | raster imagery to your viewer; it helps to have pre-tiled, 62 | [Cloud Optimized GeoTIFFs (COGs)](https://www.cogeo.org/). 63 | 64 | There is an included, standalone web viewer leveraging 65 | [CesiumJS](https://cesium.com/platform/cesiumjs/). 66 | 67 | 68 | ## ⬇️ Installation 69 | 70 | Get started with `localtileserver` to view rasters in Jupyter or deploy as your 71 | own Flask application. 72 | 73 | ### 🐍 Installing with `conda` 74 | 75 | Conda makes managing `localtileserver`'s dependencies across platforms quite 76 | easy and this is the recommended method to install: 77 | 78 | ```bash 79 | conda install -c conda-forge localtileserver 80 | ``` 81 | 82 | ### 🎡 Installing with `pip` 83 | 84 | If you prefer pip, then you can install from PyPI: https://pypi.org/project/localtileserver/ 85 | 86 | ``` 87 | pip install localtileserver 88 | ``` 89 | 90 | ## 💭 Feedback 91 | 92 | Please share your thoughts and questions on the [Discussions](https://github.com/banesullivan/localtileserver/discussions) board. 93 | If you would like to report any bugs or make feature requests, please open an issue. 94 | 95 | If filing a bug report, please share a scooby `Report`: 96 | 97 | ```py 98 | import localtileserver 99 | print(localtileserver.Report()) 100 | ``` 101 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "diff" 3 | behavior: default 4 | 5 | coverage: 6 | status: 7 | project: off 8 | patch: off 9 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/source/_static/copybutton.css: -------------------------------------------------------------------------------- 1 | /* Copy buttons */ 2 | a.copybtn { 3 | position: absolute; 4 | top: 2px; 5 | right: 2px; 6 | width: 1em; 7 | height: 1em; 8 | padding: .3em; 9 | opacity: .3; 10 | transition: opacity 0.5s; 11 | } 12 | 13 | div.highlight { 14 | position: relative; 15 | } 16 | 17 | .highlight:hover .copybtn { 18 | opacity: 1; 19 | } 20 | 21 | /** 22 | * A minimal CSS-only tooltip copied from: 23 | * https://codepen.io/mildrenben/pen/rVBrpK 24 | * 25 | * To use, write HTML like the following: 26 | * 27 | *

Short

28 | */ 29 | .o-tooltip--left { 30 | position: relative; 31 | } 32 | 33 | .o-tooltip--left:after { 34 | opacity: 0; 35 | visibility: hidden; 36 | position: absolute; 37 | content: attr(data-tooltip); 38 | padding: 2px; 39 | top: 0; 40 | left: 0; 41 | background: grey; 42 | font-size: 1rem; 43 | color: white; 44 | white-space: nowrap; 45 | z-index: 2; 46 | border-radius: 2px; 47 | transform: translateX(-102%) translateY(0); 48 | transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); 49 | } 50 | 51 | .o-tooltip--left:hover:after { 52 | display: block; 53 | opacity: 1; 54 | visibility: visible; 55 | transform: translateX(-100%) translateY(0); 56 | transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); 57 | } 58 | -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font Awesome Free License 2 | ------------------------- 3 | 4 | Font Awesome Free is free, open source, and GPL friendly. You can use it for 5 | commercial projects, open source projects, or really almost whatever you want. 6 | Full Font Awesome Free license: https://fontawesome.com/license/free. 7 | 8 | # Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) 9 | In the Font Awesome Free download, the CC BY 4.0 license applies to all icons 10 | packaged as SVG and JS file types. 11 | 12 | # Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL) 13 | In the Font Awesome Free download, the SIL OFL license applies to all icons 14 | packaged as web and desktop font files. 15 | 16 | # Code: MIT License (https://opensource.org/licenses/MIT) 17 | In the Font Awesome Free download, the MIT license applies to all non-font and 18 | non-icon files. 19 | 20 | # Attribution 21 | Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font 22 | Awesome Free files already contain embedded comments with sufficient 23 | attribution, so you shouldn't need to do anything additional when using these 24 | files normally. 25 | 26 | We've kept attribution comments terse, so we ask that you do not actively work 27 | to remove them from files, especially code. They're a great way for folks to 28 | learn about Font Awesome. 29 | 30 | # Brand Icons 31 | All brand icons are trademarks of their respective owners. The use of these 32 | trademarks does not indicate endorsement of the trademark holder by Font 33 | Awesome, nor vice versa. **Please do not use brand logos for any purpose except 34 | to represent the company, product, or service to which they refer.** 35 | -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /doc/source/_static/fontawesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/doc/source/_static/fontawesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /doc/source/_static/no_search_highlight.css: -------------------------------------------------------------------------------- 1 | /* No search term highlighting, see https://stackoverflow.com/a/48771802 */ 2 | span.highlighted { 3 | background-color: transparent; 4 | } 5 | -------------------------------------------------------------------------------- /doc/source/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {%- block scripts %} 4 | 5 | 6 | {{- script() }} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /doc/source/api/index.rst: -------------------------------------------------------------------------------- 1 | 📖 API 2 | ====== 3 | 4 | 5 | Python Client 6 | ------------- 7 | 8 | .. autofunction:: localtileserver.get_or_create_tile_client 9 | 10 | 11 | .. autoclass:: localtileserver.client.TilerInterface 12 | :members: 13 | :undoc-members: 14 | 15 | 16 | .. autoclass:: localtileserver.TileClient 17 | :members: 18 | :undoc-members: 19 | 20 | 21 | Jupyter Widget Helpers 22 | ---------------------- 23 | 24 | .. autofunction:: localtileserver.get_leaflet_tile_layer 25 | 26 | 27 | .. autofunction:: localtileserver.get_folium_tile_layer 28 | 29 | 30 | 31 | Other Helpers 32 | ------------- 33 | 34 | .. autofunction:: localtileserver.helpers.save_new_raster 35 | 36 | .. autofunction:: localtileserver.make_vsi 37 | 38 | .. autofunction:: localtileserver.validate.validate_cog 39 | 40 | .. autofunction:: localtileserver.helpers.polygon_to_geojson 41 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | 8 | import datetime 9 | 10 | import localtileserver 11 | 12 | # -- Project information ----------------------------------------------------- 13 | 14 | 15 | project = "🌐 localtileserver" 16 | year = datetime.date.today().year 17 | copyright = f"2021-{year}, Bane Sullivan" 18 | author = "Bane Sullivan" 19 | 20 | # The full version, including alpha/beta/rc tags 21 | release = localtileserver.__version__ 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [ 30 | "notfound.extension", 31 | "sphinx.ext.napoleon", 32 | "sphinx.ext.autodoc", 33 | "jupyter_sphinx", 34 | "sphinx_copybutton", 35 | # "sphinx_panels", 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"] 45 | 46 | # The name of the Pygments (syntax highlighting) style to use. 47 | pygments_style = "friendly" 48 | 49 | # If true, `todo` and `todoList` produce output, else they produce nothing. 50 | todo_include_todos = False 51 | 52 | html_title = project 53 | html_short_title = "" 54 | # html_favicon = "_static/favicon.png" 55 | html_extra_path = [] # TODO: "CNAME", 56 | html_use_smartypants = True 57 | 58 | 59 | # -- Options for HTML output ------------------------------------------------- 60 | 61 | # The theme to use for HTML and HTML Help pages. See the documentation for 62 | # a list of builtin themes. 63 | # 64 | html_theme = "pydata_sphinx_theme" 65 | html_context = { 66 | "github_user": "banesullivan", 67 | "github_repo": "localtileserver", 68 | "github_version": "main", 69 | "doc_path": "doc", 70 | } 71 | 72 | html_theme_options = { 73 | # "default_mode": "light", 74 | # "google_analytics_id": "G-14GFZDPSQG", 75 | "show_prev_next": False, 76 | "github_url": "https://github.com/banesullivan/localtileserver", 77 | "icon_links": [ 78 | { 79 | "name": "Support", 80 | "url": "https://github.com/banesullivan/localtileserver/discussions", 81 | "icon": "fa fa-comment fa-fw", 82 | }, 83 | { 84 | "name": "Author", 85 | "url": "https://banesullivan.com/", 86 | "icon": "fa fa-user fa-fw", 87 | }, 88 | ], 89 | "navbar_end": ["navbar-icon-links"], 90 | } 91 | 92 | html_sidebars = { 93 | "index": [], 94 | } 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ["_static"] 100 | 101 | 102 | notfound_context = { 103 | "body": """ 104 |

Page not found.

\n\nPerhaps try the home page.. 105 |
106 |

In the meantime, here's a bonus tile layer

107 |
108 |
109 | 124 | 125 |
126 | """, 127 | } 128 | notfound_no_urls_prefix = True 129 | 130 | # Copy button customization 131 | # exclude traditional Python prompts from the copied code 132 | copybutton_prompt_text = r">>> ?|\.\.\. " 133 | copybutton_prompt_is_regexp = True 134 | 135 | 136 | def setup(app): 137 | app.add_css_file("copybutton.css") 138 | app.add_css_file("no_search_highlight.css") 139 | app.add_css_file("fontawesome/css/all.css") 140 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | 🌐 localtileserver 2 | ================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | 7 | installation/index 8 | user-guide/index 9 | api/index 10 | 11 | 12 | 13 | *Need to visualize a rather large (gigabytes+) raster?* **This is for you.** 14 | 15 | Try it out below! 16 | 17 | .. jupyter-execute:: 18 | 19 | from localtileserver import TileClient, get_leaflet_tile_layer, examples 20 | from ipyleaflet import Map 21 | 22 | # Create a TileClient from a raster file 23 | # client = TileClient('path/to/geo.tif') 24 | client = examples.get_san_francisco() # use example data 25 | 26 | # Create ipyleaflet TileLayer from that server 27 | t = get_leaflet_tile_layer(client) 28 | # Create ipyleaflet map, add tile layer, and display 29 | m = Map(center=client.center(), zoom=client.default_zoom) 30 | m.add(t) 31 | m 32 | 33 | 34 | 35 | .. raw:: html 36 | 37 |
38 | 39 | 40 | A Python package for serving tiles from large raster files in 41 | the `Slippy Maps standard `_ 42 | (i.e., `/zoom/x/y.png`) for visualization in Jupyter with ``ipyleaflet`` or ``folium``. 43 | 44 | 45 | .. |binder| image:: https://mybinder.org/badge_logo.svg 46 | :target: https://mybinder.org/v2/gh/banesullivan/localtileserver-demo/HEAD 47 | :alt: MyBinder 48 | 49 | Launch a `demo `_ on MyBinder |binder| 50 | 51 | 52 | Built on `rio-tiler `_. 53 | 54 | 55 | 🌟 Highlights 56 | ============= 57 | 58 | - Launch a tile server for large geospatial images 59 | - View local or remote* raster files with ``ipyleaflet`` or ``folium`` in Jupyter 60 | - View rasters with CesiumJS with the built-in web application 61 | 62 | *remote raster files should be pre-tiled Cloud Optimized GeoTiffs* 63 | 64 | ℹ️ Overview 65 | =========== 66 | 67 | The :class:`TileClient` class can be used to to launch a tile server in a background 68 | thread which will serve raster imagery to a viewer (see ``ipyleaflet`` and 69 | ``folium`` examples in :ref:`user_guide`). 70 | 71 | This tile server can efficiently deliver varying resolutions of your 72 | raster imagery to your viewer; it helps to have pre-tiled, 73 | `Cloud Optimized GeoTIFFs (COG) `_. 74 | 75 | There is an included, standalone web viewer leveraging 76 | `CesiumJS `_. 77 | 78 | 79 | 🪢 Community Usage 80 | ================== 81 | 82 | - `leafmap `_ and `geemap `_: use localtileserver for visualizing large raster images in a Jupyter-based geospatial mapping application 83 | - `streamlit-geospatial `_: uses localtileserver's remote tile server for viewing image tiles 84 | - `remotetileserver `_: uses the core flask application to spin up a production ready tile server 85 | - `Kaustav Mukherjee's blog post `_: a user-created demonstration on how to get started with localtileserver 86 | - `Serving up SpaceNet Imagery for Bokeh `_: Adam Van Etten's blog post using localtileserver to view imagery with Bokeh 87 | -------------------------------------------------------------------------------- /doc/source/installation/docker.rst: -------------------------------------------------------------------------------- 1 | .. _docker: 2 | 3 | 🐳 Docker 4 | --------- 5 | 6 | Included in this repository's packages is a pre-built Docker image that can be 7 | used as a local tile serving service. To use, pull the image and run it by 8 | mounting your local volume where the imagery is stored and forward port 8000. 9 | 10 | This is particularly useful if you do not want to install the dependencies on 11 | your system or want a dedicated and isolated service for tile serving. 12 | 13 | To use the docker image: 14 | 15 | .. code:: bash 16 | 17 | docker pull ghcr.io/banesullivan/localtileserver:latest 18 | docker run -p 8000:8000 ghcr.io/banesullivan/localtileserver:latest 19 | 20 | 21 | Then visit http://localhost:8000 in your browser. You can pass the `?filename=` 22 | argument in the URL parameters to access any URL/S3 raster image file. 23 | 24 | You can mount your local file system to access files on your filesystem. For 25 | example, mount your Desktop by: 26 | 27 | .. code:: bash 28 | 29 | docker run -p 8000:8000 -v /Users/bane/Desktop/:/data/ ghcr.io/banesullivan/localtileserver:latest 30 | 31 | 32 | Then add the `?filename=` parameter to the URL in your browser to access the 33 | local files. Since this is mounted under `/data/` in the container, you must 34 | build the path as `/data/`, such that the URL would be: 35 | http://localhost:8000/?filename=/data/TC_NG_SFBay_US_Geo.tif 36 | 37 | .. note:: 38 | 39 | Check out the container on GitHub's package registry: https://github.com/banesullivan/localtileserver/pkgs/container/localtileserver 40 | 41 | 42 | .. _jupyter-docker: 43 | 44 | 📓 Jupyter in Docker 45 | ~~~~~~~~~~~~~~~~~~~~ 46 | 47 | There is also a pre-built image with localtileserver configured to be used in 48 | Jupyer from a Docker container. 49 | 50 | .. code:: bash 51 | 52 | docker run -p 8888:8888 ghcr.io/banesullivan/localtileserver-jupyter:latest 53 | 54 | 55 | .. note:: 56 | 57 | Check out the container on GitHub's package registry: https://github.com/banesullivan/localtileserver/pkgs/container/localtileserver-jupyter 58 | -------------------------------------------------------------------------------- /doc/source/installation/index.rst: -------------------------------------------------------------------------------- 1 | ⬇️ Installation 2 | =============== 3 | 4 | .. toctree:: 5 | :hidden: 6 | 7 | remote-jupyter 8 | docker 9 | 10 | 11 | Get started with ``localtileserver`` to view rasters locally in Jupyter or 12 | deploy in your own Flask application. 13 | 14 | 15 | 🐍 Installing with ``conda`` 16 | ---------------------------- 17 | 18 | .. image:: https://img.shields.io/conda/vn/conda-forge/localtileserver.svg?logo=conda-forge&logoColor=white 19 | :target: https://anaconda.org/conda-forge/localtileserver 20 | :alt: conda-forge 21 | 22 | Conda makes managing ``localtileserver``'s dependencies across platforms quite 23 | easy and this is the recommended method to install: 24 | 25 | .. code:: bash 26 | 27 | conda install -c conda-forge localtileserver ipyleaflet 28 | 29 | 30 | 🎡 Installing with ``pip`` 31 | -------------------------- 32 | 33 | .. image:: https://img.shields.io/pypi/v/localtileserver.svg?logo=python&logoColor=white 34 | :target: https://pypi.org/project/localtileserver/ 35 | :alt: PyPI 36 | 37 | 38 | If you prefer pip, then you can install from PyPI: https://pypi.org/project/localtileserver/ 39 | 40 | .. code:: bash 41 | 42 | pip install localtileserver ipyleaflet 43 | -------------------------------------------------------------------------------- /doc/source/installation/remote-jupyter.rst: -------------------------------------------------------------------------------- 1 | 📡 Remote Jupyter 2 | ----------------- 3 | 4 | ``localtileserver`` is usable in remote Jupyter environments such JupyterHub 5 | on services like MyBinder. Further, you may be running Jupyter in a Docker 6 | container or other host and accessing through a browser on an arbitrary client. 7 | In order to retrieve tiles into the ipyleaflet or folium Jupyter widgets 8 | client-side in the browser, we must make sure the port on which 9 | ``localtileserver`` is serving tiles is accessible to your browser. 10 | 11 | To make this easy, we can levarage `jupyter-server-proxy `_ to expose the port on the Jupyter server through a proxy URL. 12 | 13 | Steps to use ``localtileserver`` in remote Jupyter environments: 14 | 15 | 1. Install ``jupyter-server-proxy`` for JupyterLab >= 3 16 | 17 | .. code:: 18 | 19 | pip install jupyter-server-proxy 20 | 21 | 2. Set ``LOCALTILESERVER_CLIENT_PREFIX`` in your environment to ``'proxy/{port}'`` (stop here in most cases, continue to 3. if using JupyterHub): 22 | 23 | .. code:: 24 | 25 | export LOCALTILESERVER_CLIENT_PREFIX='proxy/{port}' 26 | 27 | 3. If using JupyterHub, you may need to alter ``LOCALTILESERVER_CLIENT_PREFIX`` such that it includes your users ID. For example, on MyBinder, we are required to do: 28 | 29 | .. code:: python 30 | 31 | # Set host forwarding for MyBinder 32 | import os 33 | os.environ['LOCALTILESERVER_CLIENT_PREFIX'] = f"{os.environ['JUPYTERHUB_SERVICE_PREFIX']}/proxy/{{port}}" 34 | 35 | 36 | .. note:: 37 | 38 | For more context, check out :ref:`jupyter-docker` 39 | -------------------------------------------------------------------------------- /doc/source/user-guide/bokeh.rst: -------------------------------------------------------------------------------- 1 | 🎨 Plotting with Bokeh 2 | ---------------------- 3 | 4 | .. jupyter-execute:: 5 | 6 | from bokeh.plotting import figure, output_file, show 7 | from bokeh.io import output_notebook 8 | from bokeh.models import WMTSTileSource 9 | from localtileserver import TileClient, examples 10 | 11 | output_notebook() 12 | 13 | client = examples.get_san_francisco() 14 | raster_provider = WMTSTileSource(url=client.get_tile_url(client=True)) 15 | bounds = client.bounds(projection='EPSG:3857') 16 | 17 | p = figure(x_range=(bounds[2], bounds[3]), y_range=(bounds[0], bounds[1]), 18 | x_axis_type="mercator", y_axis_type="mercator") 19 | p.add_tile('CARTODBPOSITRON') 20 | p.add_tile(raster_provider) 21 | show(p) 22 | -------------------------------------------------------------------------------- /doc/source/user-guide/compare.rst: -------------------------------------------------------------------------------- 1 | 🥓 Two Rasters at Once 2 | ---------------------- 3 | 4 | .. jupyter-execute:: 5 | 6 | from localtileserver import TileClient, get_leaflet_tile_layer 7 | from ipyleaflet import Map, ScaleControl, FullScreenControl, SplitMapControl 8 | 9 | # Create tile servers from two raster files 10 | l_client = TileClient('https://www.dropbox.com/s/ffdmncjaj82hf6f/L5039035_03520060512_B30.TIF?dl=0') 11 | r_client = TileClient('https://www.dropbox.com/s/ysxscp059rtrw0d/L5039035_03520060512_B70.TIF?dl=0') 12 | 13 | # Shared display parameters 14 | display = dict(vmin=50, vmax=150, colormap='coolwarm') 15 | 16 | # Create 2 tile layers from different raster 17 | l = get_leaflet_tile_layer(l_client, **display) 18 | r = get_leaflet_tile_layer(r_client, **display) 19 | 20 | # Make the ipyleaflet map 21 | m = Map(center=l_client.center(), zoom=l_client.default_zoom) 22 | control = SplitMapControl(left_layer=l, right_layer=r) 23 | m.add_control(control) 24 | m.add_control(ScaleControl(position='bottomleft')) 25 | m.add_control(FullScreenControl()) 26 | m 27 | -------------------------------------------------------------------------------- /doc/source/user-guide/example-data.rst: -------------------------------------------------------------------------------- 1 | 🗺️ Example Datasets 2 | ------------------- 3 | 4 | A few example datasets are included with `localtileserver`. A particularly 5 | useful one has global elevation data which you can use to create high resolution 6 | Digital Elevation Models (DEMs) of a local region. 7 | 8 | 9 | .. code:: python 10 | 11 | from localtileserver import get_leaflet_tile_layer, examples 12 | from ipyleaflet import Map 13 | 14 | # Load example tile layer from publicly available DEM source 15 | client = examples.get_elevation() 16 | 17 | # Create ipyleaflet tile layer from that server 18 | t = get_leaflet_tile_layer(client, 19 | indexes=1, vmin=-500, vmax=5000, 20 | colormap='plasma', 21 | opacity=0.75) 22 | 23 | m = Map(zoom=2) 24 | m.add(t) 25 | m 26 | 27 | 28 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/elevation.png 29 | 30 | 31 | Here is another example with the Virtual Earth satellite imagery 32 | 33 | .. code:: python 34 | 35 | from localtileserver import get_leaflet_tile_layer, examples 36 | from ipyleaflet import Map 37 | 38 | # Load example tile layer from publicly available imagery 39 | client = examples.get_virtual_earth() 40 | 41 | # Create ipyleaflet tile layer from that server 42 | t = get_leaflet_tile_layer(client, opacity=1) 43 | 44 | m = Map(center=(39.751343612695145, -105.22181306125279), zoom=18) 45 | m.add(t) 46 | m 47 | 48 | 49 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/kafadar.png 50 | -------------------------------------------------------------------------------- /doc/source/user-guide/hillshade.rst: -------------------------------------------------------------------------------- 1 | ⛰️ DEM Hillshade 2 | ---------------- 3 | 4 | Generate hillshade map from Digital Elevation Model (DEM). 5 | 6 | A hillshade is a 3D representation of a surface where the darker and lighter 7 | colors represent the shadows and highlights that you would visually expect to 8 | see in a terrain model. Hillshades are often used as an underlay in a map, to 9 | make the data appear more 3-Dimensional. 10 | 11 | 12 | .. note:: 13 | 14 | This example was adopted from `EarthPy `_ 15 | 16 | 17 | .. code:: python 18 | 19 | from localtileserver import TileClient, get_leaflet_tile_layer 20 | from localtileserver import examples, helpers 21 | from ipyleaflet import Map, SplitMapControl 22 | import rasterio 23 | 24 | # Example DEM dataset 25 | client = examples.get_co_elevation() 26 | 27 | tdem = get_leaflet_tile_layer(client, colormap='gist_earth', nodata=0) 28 | 29 | m = client.get_leaflet_map() 30 | m.add(tdem) 31 | m 32 | 33 | 34 | Read the DEM data as a NumPy array using rasterio: 35 | 36 | .. code:: python 37 | 38 | dem = client.dataset.read()[0, :, :] 39 | dem.shape 40 | 41 | 42 | Compute the hillshade of the DEM using the :func:`localtileserver.helpers.hillshade` 43 | function (adopted from EarthPy). 44 | 45 | .. code:: python 46 | 47 | help(helpers.hillshade) 48 | 49 | .. code:: python 50 | 51 | # Compute hillshade 52 | hs_arr = helpers.hillshade(dem) 53 | 54 | # Save hillshade arrays as new raster and open with rasterio 55 | hs = rasterio.open(helpers.save_new_raster(client, hs_arr)) 56 | 57 | 58 | .. code:: python 59 | 60 | # Make an ipyleaflet tile layer of the hillshade 61 | hst = get_leaflet_tile_layer(hs, nodata=0) 62 | 63 | m = client.get_leaflet_map() 64 | control = SplitMapControl(left_layer=tdem, right_layer=hst) 65 | m.add_control(control) 66 | m 67 | 68 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/hillshade_compare.png 69 | 70 | 71 | We can also overlay the hillshade on the original DEM so that it gives it a 3D 72 | effect: 73 | 74 | .. code:: python 75 | 76 | m = client.get_leaflet_map() 77 | m.add(tdem) 78 | m.add(get_leaflet_tile_layer(hs, opacity=0.5, nodata=0)) 79 | m 80 | 81 | 82 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/hillshade.png 83 | -------------------------------------------------------------------------------- /doc/source/user-guide/in-memory.rst: -------------------------------------------------------------------------------- 1 | 🧠 In-Memory Rasters 2 | -------------------- 3 | 4 | .. jupyter-execute:: 5 | 6 | import rasterio 7 | from ipyleaflet import Map 8 | from localtileserver import TileClient, get_leaflet_tile_layer 9 | 10 | # Open a rasterio dataset 11 | dataset = rasterio.open('https://open.gishub.org/data/raster/srtm90.tif') 12 | data_array = dataset.read(1) 13 | 14 | 15 | .. jupyter-execute:: 16 | 17 | # Do some processing on the data array 18 | data_array[data_array < 1000] = 0 19 | 20 | # Create rasterio dataset in memory 21 | memory_file = rasterio.MemoryFile() 22 | raster_dataset = memory_file.open(driver='GTiff', 23 | height=data_array.shape[0], 24 | width=data_array.shape[1], 25 | count=1, 26 | dtype=str(data_array.dtype), 27 | crs=dataset.crs, 28 | transform=dataset.transform) 29 | 30 | # Write data array values to the rasterio dataset 31 | raster_dataset.write(data_array, 1) 32 | raster_dataset.close() 33 | 34 | 35 | .. jupyter-execute:: 36 | 37 | client = TileClient(raster_dataset) 38 | client.thumbnail(colormap="terrain") 39 | -------------------------------------------------------------------------------- /doc/source/user-guide/index.rst: -------------------------------------------------------------------------------- 1 | .. _user_guide: 2 | 3 | 🚀 User Guide 4 | ============= 5 | 6 | ``localtileserver`` can be used in a few different ways: 7 | 8 | - In a Jupyter notebook with ipyleaflet or folium 9 | - From the commandline in a web browser 10 | - With remote Cloud Optimized GeoTiffs 11 | 12 | .. toctree:: 13 | :hidden: 14 | 15 | rgb 16 | remote-cog 17 | compare 18 | example-data 19 | web-app 20 | ipyleaflet_deep_zoom 21 | rasterio 22 | validate_cog 23 | bokeh 24 | hillshade 25 | in-memory 26 | 27 | 28 | Here is the "one-liner" to visualize a large geospatial image with 29 | ``ipyleaflet`` in Jupyter: 30 | 31 | .. jupyter-execute:: 32 | 33 | from localtileserver import TileClient, examples 34 | 35 | # client = TileClient('path/to/geo.tif') 36 | client = examples.get_san_francisco() # use example data 37 | client 38 | 39 | 40 | The :class:`localtileserver.TileClient` class utilizes the ``_ipython_display_`` 41 | method to automatically display the tiles with ``ipyleaflet`` in a Notebook. 42 | 43 | You can also get a single tile by: 44 | 45 | .. jupyter-execute:: 46 | 47 | # z, x, y 48 | client.tile(10, 163, 395) 49 | 50 | 51 | And get a thumbnail preview by: 52 | 53 | .. jupyter-execute:: 54 | 55 | client.thumbnail() 56 | 57 | 58 | 🍃 ``ipyleaflet`` Tile Layers 59 | ----------------------------- 60 | 61 | The :class:`TileClient` class is a nifty tool to launch a tile server as a background 62 | thread to serve image tiles from any raster file on your local file system. 63 | Additionally, it can be used in conjunction with the :func:`get_leaflet_tile_layer` 64 | utility to create an :class:`ipyleaflet.TileLayer` for interactive visualization in 65 | a Jupyter notebook. Here is an example: 66 | 67 | 68 | .. jupyter-execute:: 69 | 70 | from localtileserver import get_leaflet_tile_layer, TileClient, examples 71 | from ipyleaflet import Map 72 | 73 | # First, create a tile server from local raster file 74 | # client = TileClient('path/to/geo.tif') 75 | client = examples.get_elevation() # use example data 76 | 77 | # Create ipyleaflet tile layer from that server 78 | t = get_leaflet_tile_layer(client, 79 | indexes=1, vmin=-5000, vmax=5000, 80 | opacity=0.65) 81 | 82 | # Create ipyleaflet map, add tile layer, and display 83 | m = Map(zoom=3) 84 | m.add(t) 85 | m 86 | 87 | 88 | 🌳 ``folium`` Tile Layers 89 | ------------------------- 90 | 91 | Similarly to the support provided for ``ipyleaflet``, I have included a utility 92 | to generate a :class:`folium.TileLayer` (see `reference `_) 93 | with :func:`get_folium_tile_layer`. Here is an example with almost the exact same 94 | code as the ``ipyleaflet`` example, just note that :class:`folium.Map` is imported from 95 | ``folium`` and we use :func:`add_child` instead of :func:`add`: 96 | 97 | 98 | .. jupyter-execute:: 99 | 100 | from localtileserver import get_folium_tile_layer, TileClient, examples 101 | from folium import Map 102 | 103 | # First, create a tile server from local raster file 104 | # client = TileClient('path/to/geo.tif') 105 | client = examples.get_oam2() # use example data 106 | 107 | # Create folium tile layer from that server 108 | t = get_folium_tile_layer(client) 109 | 110 | m = Map(location=client.center(), zoom_start=16) 111 | m.add_child(t) 112 | m 113 | 114 | 115 | 116 | 🗒️ Usage Notes 117 | -------------- 118 | 119 | - :func:`get_leaflet_tile_layer` accepts either an existing :class:`TileClient` or a path from which to create a :class:`TileClient` under the hood. 120 | - If matplotlib is installed, any matplotlib colormap name cane be used a palette choice 121 | 122 | 123 | 💭 Feedback 124 | ----------- 125 | 126 | Please share your thoughts and questions on the `Discussions `_ board. 127 | If you would like to report any bugs or make feature requests, please open an issue. 128 | 129 | If filing a bug report, please share a scooby ``Report``: 130 | 131 | 132 | .. code:: python 133 | 134 | import localtileserver 135 | print(localtileserver.Report()) 136 | -------------------------------------------------------------------------------- /doc/source/user-guide/ipyleaflet_deep_zoom.rst: -------------------------------------------------------------------------------- 1 | 🔬 Deep Zoom with ``ipyleaflet`` 2 | -------------------------------- 3 | 4 | In order to perform deep zooming of tile sources with ``ipyleaflet``, you must 5 | specify a few keyword arguments: 6 | 7 | - ``max_zoom`` and ``max_native_zoom`` set appropriately in the ``TileLayer`` 8 | - ``max_zoom`` set in the ``Map`` which matches ``max_zoom`` in the ``TileLayer`` 9 | 10 | For more information, please see https://github.com/jupyter-widgets/ipyleaflet/issues/925 11 | 12 | .. jupyter-execute:: 13 | 14 | from localtileserver import get_leaflet_tile_layer, examples 15 | from ipyleaflet import Map, TileLayer 16 | 17 | # Load high res raster 18 | client = examples.get_oam2() 19 | 20 | max_zoom = 30 21 | 22 | # Create zoomable tile layer from high res raster 23 | layer = get_leaflet_tile_layer(client, 24 | # extra kwargs to pass to the TileLayer 25 | max_zoom=max_zoom, 26 | max_native_zoom=max_zoom, 27 | ) 28 | 29 | # Make the ipyleaflet map with deeper zoom 30 | m = Map(center=client.center(), 31 | zoom=22, max_zoom=max_zoom 32 | ) 33 | m.add(layer) 34 | m 35 | -------------------------------------------------------------------------------- /doc/source/user-guide/rasterio.rst: -------------------------------------------------------------------------------- 1 | 🧩 Rasterio 2 | ----------- 3 | 4 | ``localtileserver.TileClient`` supports viewing ``rasterio.DatasetReader`` 5 | so that you can easily visualize your data when working with rasterio. 6 | This will only work when opening a raster in read-mode. 7 | 8 | 9 | .. code-block:: python 10 | 11 | import rasterio 12 | from ipyleaflet import Map 13 | from localtileserver import TileClient, get_leaflet_tile_layer 14 | 15 | src = rasterio.open('path/to/geo.tif') 16 | 17 | client = TileClient(src) 18 | 19 | t = get_leaflet_tile_layer(client) 20 | 21 | m = Map(center=client.center(), zoom=client.default_zoom) 22 | m.add(t) 23 | m 24 | 25 | 26 | ``localtileserver`` actually uses ``rasterio`` under the hood for everything 27 | and keeps a reference to a ``rasterio.DatasetReader`` for all clients. 28 | 29 | 30 | .. code-block:: python 31 | 32 | from localtileserver import examples 33 | 34 | # Load example tile layer from publicly available DEM source 35 | client = examples.get_elevation() 36 | 37 | client.dataset 38 | -------------------------------------------------------------------------------- /doc/source/user-guide/remote-cog.rst: -------------------------------------------------------------------------------- 1 | ☁️ Remote Cloud Optimized GeoTiffs (COGs) 2 | ----------------------------------------- 3 | 4 | While ``localtileserver`` is intended to be used only with raster files existing 5 | on your local filesystem, there is support for URL files through GDAL's 6 | `Virtual Storage Interface `_. 7 | Simply pass your ``http://`` or ``s3://`` URL to the :class:`TileClient`. This will 8 | work quite well for pre-tiled Cloud Optimized GeoTiffs, but I do not recommend 9 | doing this with non-tiled raster formats. 10 | 11 | For example, the raster at the url below is ~3GiB but because it is pre-tiled, 12 | we can view tiles of the remote file very efficiently in a Jupyter notebook. 13 | 14 | .. jupyter-execute:: 15 | 16 | from localtileserver import get_folium_tile_layer, get_leaflet_tile_layer 17 | from localtileserver import TileClient 18 | import folium, ipyleaflet 19 | 20 | url = 'https://github.com/giswqs/data/raw/main/raster/landsat7.tif' 21 | 22 | # First, create a tile server from the URL raster file 23 | client = TileClient(url) 24 | 25 | 26 | Here we can create a folium map with the raster overlain: 27 | 28 | .. jupyter-execute:: 29 | 30 | # Create folium tile layer from that server 31 | t = get_folium_tile_layer(client) 32 | 33 | m = folium.Map(location=client.center(), zoom_start=client.default_zoom) 34 | m.add_child(t) 35 | m 36 | 37 | 38 | Or we can do the same ipyleaflet: 39 | 40 | .. jupyter-execute:: 41 | 42 | # Create ipyleaflet tile layer from that server 43 | l = get_leaflet_tile_layer(client) 44 | 45 | m = ipyleaflet.Map(center=client.center(), zoom=client.default_zoom) 46 | m.add(l) 47 | m 48 | 49 | 50 | .. note:: 51 | 52 | Note that the Virtual Storage Interface is a complex API, and :class:`TileClient` 53 | currently only handles ``vsis3`` and ``vsicurl``. If you need a different VFS 54 | mechanism, simply create your ``/vsi`` path and pass that to :class:`TileClient`. 55 | -------------------------------------------------------------------------------- /doc/source/user-guide/rgb.rst: -------------------------------------------------------------------------------- 1 | 🧮 Controlling the RGB Bands 2 | ---------------------------- 3 | 4 | The ``ipyleaflet`` and ``folium`` tile layer utilities support setting which bands 5 | to view as the RGB channels. To set the RGB bands, pass a length three list 6 | of the band indices to the ``indexes`` argument. 7 | 8 | Here is an example where I create two tile layers from the same raster but 9 | viewing a different set of bands: 10 | 11 | .. jupyter-execute:: 12 | 13 | from localtileserver import get_leaflet_tile_layer, examples 14 | from ipyleaflet import Map, ScaleControl, FullScreenControl, SplitMapControl 15 | 16 | # First, create TileClient using example file 17 | client = examples.get_landsat() 18 | 19 | 20 | .. jupyter-execute:: 21 | 22 | client.thumbnail(indexes=[7, 5, 4]) 23 | 24 | 25 | .. jupyter-execute:: 26 | 27 | client.thumbnail(indexes=[5, 3, 2]) 28 | 29 | 30 | .. jupyter-execute:: 31 | 32 | # Create 2 tile layers from same raster viewing different bands 33 | l = get_leaflet_tile_layer(client, indexes=[7, 5, 4]) 34 | r = get_leaflet_tile_layer(client, indexes=[5, 3, 2]) 35 | 36 | # Make the ipyleaflet map 37 | m = Map(center=client.center(), zoom=client.default_zoom) 38 | control = SplitMapControl(left_layer=l, right_layer=r) 39 | m.add_control(control) 40 | m.add_control(ScaleControl(position='bottomleft')) 41 | m.add_control(FullScreenControl()) 42 | m 43 | -------------------------------------------------------------------------------- /doc/source/user-guide/validate_cog.rst: -------------------------------------------------------------------------------- 1 | ✅ Validate COG 2 | --------------- 3 | 4 | ``localtileserver`` includes a helper method to validate whether or not a 5 | source image meets the requirements of a Cloud Optimized GeoTiff. 6 | 7 | :func:`localtileserver.validate.validate_cog` uses rio-cogeo to validate 8 | whether or not a source image meets the requirements of a Cloud Optimized 9 | GeoTIFF. 10 | 11 | You can use the script by: 12 | 13 | .. jupyter-execute:: 14 | 15 | from localtileserver import validate_cog 16 | 17 | # Path to raster (URL or local path) 18 | url = 'https://github.com/giswqs/data/raw/main/raster/landsat7.tif' 19 | 20 | # If invalid, returns False 21 | validate_cog(url) 22 | 23 | 24 | This can also be used with an existing :class:`localtileserver.TileClient`: 25 | 26 | .. jupyter-execute:: 27 | 28 | from localtileserver import examples, validate_cog 29 | 30 | client = examples.get_san_francisco() 31 | 32 | # If invalid, returns False 33 | validate_cog(client) 34 | -------------------------------------------------------------------------------- /doc/source/user-guide/web-app.rst: -------------------------------------------------------------------------------- 1 | 🖥️ Local Web Application 2 | ------------------------ 3 | 4 | Launch the tileserver from the commandline to use the included web application where you can view the raster and extract regions of interest. 5 | 6 | .. code:: bash 7 | 8 | python -m localtileserver path/to/raster.tif 9 | 10 | 11 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/cesium-viewer.png 12 | 13 | You can use the web viewer to extract regions of interest: 14 | 15 | .. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/webviewer-roi.gif 16 | 17 | 18 | You can also launch the web viewer with any of the available example datasets: 19 | 20 | .. code:: bash 21 | 22 | python -m localtileserver dem 23 | 24 | 25 | Available choices are: 26 | 27 | - ``dem`` or ``elevation``: global elevation dataset 28 | - ``blue_marble``: Blue Marble satellite imagery 29 | - ``virtual_earth``: Microsoft's satellite/aerial imagery 30 | - ``arcgis``: ArcGIS World Street Map 31 | - ``bahamas``: Sample raster over the Bahamas 32 | -------------------------------------------------------------------------------- /example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "fd865f8c-b003-4641-88c8-f182b98b51ae", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "from ipyleaflet import Map, projections\n", 11 | "from ipyleaflet import ScaleControl, FullScreenControl, SplitMapControl\n", 12 | "\n", 13 | "from localtileserver import examples\n", 14 | "from localtileserver import TileClient, get_leaflet_tile_layer" 15 | ] 16 | }, 17 | { 18 | "attachments": {}, 19 | "cell_type": "markdown", 20 | "id": "6ef89559-cf30-478a-a1b6-f69f3cc7320e", 21 | "metadata": {}, 22 | "source": [ 23 | "## Bahamas RGB" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "f455f44d-9de9-42fb-aeca-d652c793eb01", 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "# First, create a tile server from raster file\n", 34 | "b_client = examples.get_bahamas()\n", 35 | "\n", 36 | "# Create ipyleaflet tile layer from that server\n", 37 | "t = get_leaflet_tile_layer(b_client)\n", 38 | "\n", 39 | "# Create ipyleaflet map, add tile layer, and display\n", 40 | "m = Map(center=b_client.center(), zoom=b_client.default_zoom)\n", 41 | "m.add(t)\n", 42 | "m" 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "id": "917ef5ad-f8e7-4da9-a7ce-c37a7d9e38c1", 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [] 52 | }, 53 | { 54 | "attachments": {}, 55 | "cell_type": "markdown", 56 | "id": "62d2f2d6-3b8d-4c8f-a6c3-7848acf90ac3", 57 | "metadata": {}, 58 | "source": [ 59 | "## Multiband Landsat Compare" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": null, 65 | "id": "0cba4a42-60b8-4317-a16d-7fbc854630da", 66 | "metadata": {}, 67 | "outputs": [], 68 | "source": [ 69 | "# First, create a tile server from raster file\n", 70 | "landsat_client = examples.get_landsat()\n", 71 | "\n", 72 | "# Create 2 tile layers from same raster viewing different bands\n", 73 | "l = get_leaflet_tile_layer(landsat_client, indexes=[7, 5, 4])\n", 74 | "r = get_leaflet_tile_layer(landsat_client, indexes=[5, 3, 2])\n", 75 | "\n", 76 | "# Make the ipyleaflet map\n", 77 | "m = Map(center=landsat_client.center(), zoom=landsat_client.default_zoom)\n", 78 | "control = SplitMapControl(left_layer=l, right_layer=r)\n", 79 | "m.add_control(control)\n", 80 | "m.add_control(ScaleControl(position=\"bottomleft\"))\n", 81 | "m.add_control(FullScreenControl())\n", 82 | "m" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "id": "1468bb2a-aa61-453c-a174-0dfc5a228b77", 89 | "metadata": {}, 90 | "outputs": [], 91 | "source": [] 92 | } 93 | ], 94 | "metadata": { 95 | "kernelspec": { 96 | "display_name": "Geospatial (rio)", 97 | "language": "python", 98 | "name": "pyenv_rio" 99 | }, 100 | "language_info": { 101 | "codemirror_mode": { 102 | "name": "ipython", 103 | "version": 3 104 | }, 105 | "file_extension": ".py", 106 | "mimetype": "text/x-python", 107 | "name": "python", 108 | "nbconvert_exporter": "python", 109 | "pygments_lexer": "ipython3", 110 | "version": "3.11.6" 111 | } 112 | }, 113 | "nbformat": 4, 114 | "nbformat_minor": 5 115 | } 116 | -------------------------------------------------------------------------------- /flask.env: -------------------------------------------------------------------------------- 1 | export FLASK_APP=localtileserver/web/__init__.py 2 | export FLASK_ENV=development 3 | -------------------------------------------------------------------------------- /ignore_words.txt: -------------------------------------------------------------------------------- 1 | slippy 2 | hist 3 | -------------------------------------------------------------------------------- /imgs/bahamas-tiles-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/bahamas-tiles-wide.png -------------------------------------------------------------------------------- /imgs/cesium-viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/cesium-viewer.png -------------------------------------------------------------------------------- /imgs/elevation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/elevation.png -------------------------------------------------------------------------------- /imgs/folium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/folium.png -------------------------------------------------------------------------------- /imgs/golden-dem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/golden-dem.png -------------------------------------------------------------------------------- /imgs/golden-roi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/golden-roi.png -------------------------------------------------------------------------------- /imgs/hillshade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/hillshade.png -------------------------------------------------------------------------------- /imgs/hillshade_compare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/hillshade_compare.png -------------------------------------------------------------------------------- /imgs/ipyleaflet-draw-roi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/ipyleaflet-draw-roi.png -------------------------------------------------------------------------------- /imgs/ipyleaflet-multi-bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/ipyleaflet-multi-bands.png -------------------------------------------------------------------------------- /imgs/ipyleaflet.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/ipyleaflet.gif -------------------------------------------------------------------------------- /imgs/ipyleaflet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/ipyleaflet.png -------------------------------------------------------------------------------- /imgs/kafadar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/kafadar.png -------------------------------------------------------------------------------- /imgs/oam-tiles.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/oam-tiles.jpg -------------------------------------------------------------------------------- /imgs/oam-tiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/oam-tiles.png -------------------------------------------------------------------------------- /imgs/pip-gdal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/pip-gdal.jpg -------------------------------------------------------------------------------- /imgs/presidio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/presidio.png -------------------------------------------------------------------------------- /imgs/tile-diagram.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/tile-diagram.gif -------------------------------------------------------------------------------- /imgs/tile-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/tile-diagram.png -------------------------------------------------------------------------------- /imgs/vsi-raster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/vsi-raster.png -------------------------------------------------------------------------------- /imgs/webviewer-roi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/webviewer-roi.gif -------------------------------------------------------------------------------- /imgs/webviewer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/imgs/webviewer.gif -------------------------------------------------------------------------------- /jupyter.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jupyter/base-notebook:python-3.11.6 2 | LABEL maintainer="Bane Sullivan" 3 | LABEL repo="https://github.com/banesullivan/localtileserver" 4 | 5 | USER jovyan 6 | 7 | WORKDIR /build-context 8 | 9 | RUN python -m pip install --upgrade pip 10 | 11 | COPY requirements.txt /build-context/ 12 | COPY requirements_jupyter.txt /build-context/ 13 | RUN pip install -r requirements_jupyter.txt 14 | RUN pip install rasterio 15 | 16 | COPY setup.py /build-context/ 17 | COPY MANIFEST.in /build-context/ 18 | COPY localtileserver/ /build-context/localtileserver/ 19 | RUN python setup.py bdist_wheel 20 | RUN pip install dist/localtileserver*.whl 21 | 22 | WORKDIR $HOME 23 | 24 | COPY example.ipynb $HOME 25 | 26 | ENV JUPYTER_ENABLE_LAB=yes 27 | 28 | ARG LOCALTILESERVER_CLIENT_PREFIX='proxy/{port}' 29 | ENV LOCALTILESERVER_CLIENT_PREFIX=$LOCALTILESERVER_CLIENT_PREFIX 30 | -------------------------------------------------------------------------------- /localtileserver/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | from localtileserver._version import __version__ 3 | from localtileserver.client import TileClient, get_or_create_tile_client 4 | from localtileserver.helpers import hillshade, parse_shapely, polygon_to_geojson, save_new_raster 5 | from localtileserver.report import Report 6 | from localtileserver.tiler import get_cache_dir, make_vsi, purge_cache 7 | from localtileserver.validate import validate_cog 8 | from localtileserver.widgets import ( 9 | LocalTileServerLayerMixin, 10 | get_folium_tile_layer, 11 | get_leaflet_tile_layer, 12 | ) 13 | -------------------------------------------------------------------------------- /localtileserver/__main__.py: -------------------------------------------------------------------------------- 1 | # Import as run_app for entry_point 2 | from localtileserver.web.application import click_run_app as run_app 3 | 4 | if __name__ == "__main__": # pragma: no cover 5 | run_app() 6 | -------------------------------------------------------------------------------- /localtileserver/_version.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import DistributionNotFound, get_distribution 2 | 3 | try: 4 | __version__ = get_distribution("localtileserver").version 5 | except DistributionNotFound: # pragma: no cover 6 | # package is not installed 7 | __version__ = None 8 | -------------------------------------------------------------------------------- /localtileserver/configure.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_default_client_params(host: str = None, port: int = None, prefix: str = None): 5 | if ( 6 | host is None 7 | and "LOCALTILESERVER_CLIENT_HOST" in os.environ 8 | and os.environ["LOCALTILESERVER_CLIENT_HOST"] 9 | ): 10 | host = str(os.environ["LOCALTILESERVER_CLIENT_HOST"]) 11 | if ( 12 | port is None 13 | and "LOCALTILESERVER_CLIENT_PORT" in os.environ 14 | and os.environ["LOCALTILESERVER_CLIENT_PORT"] 15 | ): 16 | port = int(os.environ["LOCALTILESERVER_CLIENT_PORT"]) 17 | if ( 18 | prefix is None 19 | and "LOCALTILESERVER_CLIENT_PREFIX" in os.environ 20 | and os.environ["LOCALTILESERVER_CLIENT_PREFIX"] 21 | ): 22 | prefix = str(os.environ["LOCALTILESERVER_CLIENT_PREFIX"]) 23 | return host, port, prefix 24 | -------------------------------------------------------------------------------- /localtileserver/examples.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Union 3 | 4 | from localtileserver.client import TileClient 5 | from localtileserver.helpers import parse_shapely 6 | from localtileserver.tiler import ( 7 | get_co_elevation_url, 8 | get_data_path, 9 | get_elevation_us_url, 10 | get_oam2_url, 11 | get_sf_bay_url, 12 | ) 13 | from localtileserver.tiler.data import DIRECTORY 14 | 15 | 16 | def _get_example_client( 17 | port: Union[int, str] = "default", 18 | debug: bool = False, 19 | client_port: int = None, 20 | client_host: str = None, 21 | client_prefix: str = None, 22 | ): 23 | raise NotImplementedError # pragma: no cover 24 | 25 | 26 | @wraps(_get_example_client) 27 | def get_blue_marble(*args, **kwargs): 28 | path = get_data_path("frmt_wms_bluemarble_s3_tms.xml") 29 | return TileClient(path, *args, **kwargs) 30 | 31 | 32 | @wraps(_get_example_client) 33 | def get_virtual_earth(*args, **kwargs): 34 | path = get_data_path("frmt_wms_virtualearth.xml") 35 | return TileClient(path, *args, **kwargs) 36 | 37 | 38 | @wraps(_get_example_client) 39 | def get_arcgis(*args, **kwargs): 40 | path = get_data_path("frmt_wms_arcgis_mapserver_tms.xml") 41 | return TileClient(path, *args, **kwargs) 42 | 43 | 44 | @wraps(_get_example_client) 45 | def get_elevation(*args, **kwargs): 46 | path = get_data_path("aws_elevation_tiles_prod.xml") 47 | return TileClient(path, *args, **kwargs) 48 | 49 | 50 | @wraps(_get_example_client) 51 | def get_bahamas(*args, **kwargs): 52 | path = get_data_path("bahamas_rgb.tif") 53 | return TileClient(path, *args, **kwargs) 54 | 55 | 56 | @wraps(_get_example_client) 57 | def get_landsat(*args, **kwargs): 58 | path = get_data_path("landsat.tif") 59 | return TileClient(path, *args, **kwargs) 60 | 61 | 62 | @wraps(_get_example_client) 63 | def get_landsat7(*args, **kwargs): 64 | path = get_data_path("landsat7.tif") 65 | return TileClient(path, *args, **kwargs) 66 | 67 | 68 | @wraps(_get_example_client) 69 | def get_san_francisco(*args, **kwargs): 70 | path = get_sf_bay_url() 71 | return TileClient(path, *args, **kwargs) 72 | 73 | 74 | @wraps(_get_example_client) 75 | def get_oam2(*args, **kwargs): 76 | path = get_oam2_url() 77 | return TileClient(path, *args, **kwargs) 78 | 79 | 80 | @wraps(_get_example_client) 81 | def get_elevation_us(*args, **kwargs): 82 | path = get_elevation_us_url() 83 | return TileClient(path, *args, **kwargs) 84 | 85 | 86 | def load_presidio(): 87 | """Load Presidio of San Francisco boundary as Shapely Polygon.""" 88 | with open(DIRECTORY / "presidio.wkb", "rb") as f: 89 | return parse_shapely(f.read()) 90 | 91 | 92 | @wraps(_get_example_client) 93 | def get_co_elevation(*args, local_roi=False, **kwargs): 94 | if local_roi: 95 | path = get_data_path("co_elevation_roi.tif") 96 | else: 97 | path = get_co_elevation_url() 98 | return TileClient(path, *args, **kwargs) 99 | -------------------------------------------------------------------------------- /localtileserver/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | import numpy as np 5 | import rasterio 6 | 7 | from localtileserver.tiler import get_cache_dir 8 | 9 | 10 | def get_extensions_from_driver(driver: str): 11 | d = rasterio.drivers.raster_driver_extensions() 12 | if driver not in d.values(): 13 | raise KeyError(f"Driver {driver} not found.") 14 | return [k for k, v in d.items() if v == driver] 15 | 16 | 17 | def numpy_to_raster(ras_meta, data, out_path: str = None): 18 | """Save new raster from a numpy array using the metadata of another raster. 19 | 20 | Note 21 | ---- 22 | Requires ``rasterio`` 23 | 24 | Parameters 25 | ---------- 26 | ras_meta : dict 27 | Raster metadata 28 | data : np.ndarray 29 | The bands of data to save to the new raster 30 | out_path : Optional[str] 31 | The path for which to write the new raster. If ``None``, this will 32 | use a temporary file 33 | 34 | """ 35 | if data.ndim == 2: 36 | data = data[np.newaxis, ...] 37 | 38 | ras_meta = ras_meta.copy() 39 | ras_meta.update({"count": data.shape[0]}) 40 | ras_meta.update({"dtype": str(data.dtype)}) 41 | ras_meta.update({"height": data.shape[1]}) 42 | ras_meta.update({"width": data.shape[2]}) 43 | ras_meta.update({"compress": "lzw"}) 44 | ras_meta.update({"driver": "GTiff"}) 45 | 46 | if not out_path: 47 | ext = get_extensions_from_driver(ras_meta["driver"])[0] 48 | out_path = get_cache_dir() / f"{uuid.uuid4()}.{ext}" 49 | 50 | with rasterio.open(out_path, "w", **ras_meta) as dst: 51 | for i, band in enumerate(data): 52 | dst.write(band, i + 1) 53 | 54 | return out_path 55 | 56 | 57 | def save_new_raster(src, data, out_path: str = None): 58 | """Save new raster from a numpy array using the metadata of another raster. 59 | 60 | Note 61 | ---- 62 | Requires ``rasterio`` 63 | 64 | Parameters 65 | ---------- 66 | src : str, DatasetReader, TilerInterface 67 | The source rasterio data whose spatial reference will be copied 68 | data : np.ndarray 69 | The bands of data to save to the new raster 70 | out_path : Optional[str] 71 | The path for which to write the new raster. If ``None``, this will 72 | use a temporary file 73 | 74 | """ 75 | from localtileserver.client import TilerInterface 76 | 77 | if data.ndim == 2: 78 | data = data.reshape((1, *data.shape)) 79 | if data.ndim != 3: 80 | raise AssertionError("data must be ndim 3: (bands, height, width)") 81 | 82 | if isinstance(src, TilerInterface): 83 | src = src.dataset 84 | if isinstance(src, rasterio.io.DatasetReaderBase): 85 | ras_meta = src.meta.copy() 86 | else: 87 | with rasterio.open(src, "r") as src: 88 | # Get metadata / spatial reference 89 | ras_meta = src.meta 90 | 91 | return numpy_to_raster(ras_meta, data, out_path) 92 | 93 | 94 | def polygon_to_geojson(polygon) -> str: 95 | """Dump shapely.Polygon to GeoJSON.""" 96 | # Safely import shapely 97 | try: 98 | from shapely.geometry import mapping 99 | except ImportError as e: # pragma: no cover 100 | raise ImportError(f"Please install `shapely`: {e}") 101 | 102 | features = [{"type": "Feature", "properties": {}, "geometry": mapping(polygon)}] 103 | return json.dumps(features) 104 | 105 | 106 | def parse_shapely(context): 107 | """Convert GeoJSON-like or WKT to shapely object. 108 | 109 | Parameters 110 | ---------- 111 | context : str, dict 112 | a GeoJSON-like dict, which provides a "type" member describing the type 113 | of the geometry and "coordinates" member providing a list of coordinates, 114 | or an object which implements __geo_interface__. 115 | If a string, falls back to inferring as Well Known Text (WKT). 116 | """ 117 | try: 118 | from shapely.geometry import shape 119 | import shapely.wkb 120 | import shapely.wkt 121 | except ImportError as e: # pragma: no cover 122 | raise ImportError(f"Please install `shapely`: {e}") 123 | if isinstance(context, str): 124 | # Infer as WKT 125 | return shapely.wkt.loads(context) 126 | elif isinstance(context, bytes): 127 | # Infer as WKB 128 | return shapely.wkb.loads(context) 129 | return shape(context) 130 | 131 | 132 | def hillshade(arr, azimuth=30, altitude=30): 133 | """Create hillshade from a numpy array containing elevation data. 134 | 135 | Note 136 | ---- 137 | Originally sourced from earthpy: https://github.com/earthlab/earthpy/blob/9ad455e85002a2b026c78685329f8c5b360fde5a/earthpy/spatial.py#L564 138 | 139 | Parameters 140 | ---------- 141 | arr : numpy array of shape (rows, columns) 142 | Numpy array with elevation values to be used to created hillshade. 143 | azimuth : float (default=30) 144 | The desired azimuth for the hillshade. 145 | altitude : float (default=30) 146 | The desired sun angle altitude for the hillshade. 147 | Returns 148 | ------- 149 | numpy array 150 | A numpy array containing hillshade values. 151 | 152 | License 153 | ------- 154 | BSD 3-Clause License 155 | 156 | Copyright (c) 2018, Earth Lab 157 | All rights reserved. 158 | 159 | Redistribution and use in source and binary forms, with or without 160 | modification, are permitted provided that the following conditions are met: 161 | 162 | * Redistributions of source code must retain the above copyright notice, this 163 | list of conditions and the following disclaimer. 164 | 165 | * Redistributions in binary form must reproduce the above copyright notice, 166 | this list of conditions and the following disclaimer in the documentation 167 | and/or other materials provided with the distribution. 168 | 169 | * Neither the name of the copyright holder nor the names of its 170 | contributors may be used to endorse or promote products derived from 171 | this software without specific prior written permission. 172 | 173 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 174 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 175 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 176 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 177 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 178 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 179 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 180 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 181 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 182 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 183 | 184 | """ 185 | try: 186 | x, y = np.gradient(arr) 187 | except ValueError: 188 | raise ValueError("Input array should be two-dimensional") 189 | 190 | if azimuth <= 360.0: 191 | azimuth = 360.0 - azimuth 192 | azimuthrad = azimuth * np.pi / 180.0 193 | else: 194 | raise ValueError("Azimuth value should be less than or equal to 360 degrees") 195 | 196 | if altitude <= 90.0: 197 | altituderad = altitude * np.pi / 180.0 198 | else: 199 | raise ValueError("Altitude value should be less than or equal to 90 degrees") 200 | 201 | slope = np.pi / 2.0 - np.arctan(np.sqrt(x * x + y * y)) 202 | aspect = np.arctan2(-x, y) 203 | 204 | shaded = np.sin(altituderad) * np.sin(slope) + np.cos(altituderad) * np.cos(slope) * np.cos( 205 | (azimuthrad - np.pi / 2.0) - aspect 206 | ) 207 | 208 | return 255 * (shaded + 1) / 2 209 | -------------------------------------------------------------------------------- /localtileserver/manager.py: -------------------------------------------------------------------------------- 1 | from localtileserver.web import create_app 2 | 3 | 4 | class AppManager: 5 | _APP = None 6 | 7 | def __init__(self): 8 | raise NotImplementedError( 9 | "The ServerManager class cannot be instantiated." 10 | ) # pragma: no cover 11 | 12 | @staticmethod 13 | def get_or_create_app(cors_all: bool = False): 14 | if not AppManager._APP: 15 | AppManager._APP = create_app(cors_all=cors_all) 16 | return AppManager._APP 17 | -------------------------------------------------------------------------------- /localtileserver/report.py: -------------------------------------------------------------------------------- 1 | import scooby 2 | 3 | 4 | class Report(scooby.Report): 5 | def __init__(self, additional=None, ncol=3, text_width=80, sort=False): 6 | """Generate a report on the dependencies of localtileserver in this environment.""" 7 | 8 | # Mandatory packages. 9 | core = ["localtileserver"] + sorted( 10 | [ 11 | "rasterio", 12 | "rio_tiler", 13 | "numpy", 14 | "server_thread", 15 | "flask", 16 | "flask_caching", 17 | "flask_cors", 18 | "flask_restx", 19 | "rio_cogeo", 20 | "werkzeug", 21 | "click", 22 | ] 23 | ) 24 | 25 | # Optional packages. 26 | optional = sorted( 27 | [ 28 | "gunicorn", 29 | "ipyleaflet", 30 | "jupyterlab", 31 | "jupyter_server_proxy", 32 | "traitlets", 33 | "shapely", 34 | "folium", 35 | "matplotlib", 36 | "requests" "colorcet", 37 | "cmocean", 38 | ] 39 | ) 40 | 41 | scooby.Report.__init__( 42 | self, 43 | additional=additional, 44 | core=core, 45 | optional=optional, 46 | ncol=ncol, 47 | text_width=text_width, 48 | sort=sort, 49 | ) 50 | -------------------------------------------------------------------------------- /localtileserver/tiler/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | from localtileserver.tiler.data import ( 3 | get_building_docs, 4 | get_co_elevation_url, 5 | get_data_path, 6 | get_elevation_us_url, 7 | get_oam2_url, 8 | get_sf_bay_url, 9 | str_to_bool, 10 | ) 11 | from localtileserver.tiler.handler import ( 12 | get_meta_data, 13 | get_point, 14 | get_preview, 15 | get_reader, 16 | get_source_bounds, 17 | get_tile, 18 | ) 19 | from localtileserver.tiler.palettes import get_palettes, palette_valid_or_raise 20 | from localtileserver.tiler.utilities import ( 21 | ImageBytes, 22 | format_to_encoding, 23 | get_cache_dir, 24 | get_clean_filename, 25 | make_vsi, 26 | purge_cache, 27 | ) 28 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/.gitignore: -------------------------------------------------------------------------------- 1 | *.aux.xml 2 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | DIRECTORY = pathlib.Path(__file__).parent 5 | 6 | 7 | def str_to_bool(v): 8 | return v.lower() in ("yes", "true", "t", "1", "on", "y") 9 | 10 | 11 | def get_building_docs(): 12 | if "LOCALTILESERVER_BUILDING_DOCS" in os.environ and str_to_bool( 13 | os.environ["LOCALTILESERVER_BUILDING_DOCS"] 14 | ): 15 | return True 16 | return False 17 | 18 | 19 | def get_data_path(name): 20 | if get_building_docs(): 21 | return f"https://github.com/banesullivan/localtileserver/raw/main/localtileserver/tiler/data/{name}" 22 | else: 23 | return DIRECTORY / name 24 | 25 | 26 | def get_sf_bay_url(): 27 | return "https://localtileserver.s3.us-west-2.amazonaws.com/examples/TC_NG_SFBay_US_Geo_COG.tif" 28 | 29 | 30 | def get_elevation_us_url(): 31 | return "https://localtileserver.s3.us-west-2.amazonaws.com/examples/elevation_cog.tif" 32 | 33 | 34 | def get_oam2_url(): 35 | return "https://localtileserver.s3.us-west-2.amazonaws.com/examples/oam2.tif" 36 | 37 | 38 | def convert_dropbox_url(url: str): 39 | return url.replace("https://www.dropbox.com", "https://dl.dropbox.com") 40 | 41 | 42 | def clean_url(url: str): 43 | """Fix the download URL for common hosting services like dropbox.""" 44 | return convert_dropbox_url(url) 45 | 46 | 47 | def get_co_elevation_url(): 48 | return "https://localtileserver.s3.us-west-2.amazonaws.com/examples/co_elevation.tif" 49 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/aws_elevation_tiles_prod.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://s3.amazonaws.com/elevation-tiles-prod/geotiff/${z}/${x}/${y}.tif 4 | 5 | 6 | -20037508.34 7 | 20037508.34 8 | 20037508.34 9 | -20037508.34 10 | 14 11 | top 12 | 13 | EPSG:3857 14 | 512 15 | 512 16 | 1 17 | Int16 18 | 403,404 19 | 20 | -32768 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/bahamas_rgb.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/localtileserver/tiler/data/bahamas_rgb.tif -------------------------------------------------------------------------------- /localtileserver/tiler/data/co_elevation_roi.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/localtileserver/tiler/data/co_elevation_roi.tif -------------------------------------------------------------------------------- /localtileserver/tiler/data/frmt_wms_arcgis_mapserver_tms.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/${z}/${y}/${x} 4 | 5 | 6 | -20037508.34 7 | 20037508.34 8 | 20037508.34 9 | -20037508.34 10 | 17 11 | 1 12 | 1 13 | top 14 | 15 | EPSG:900913 16 | 256 17 | 256 18 | 3 19 | 10 20 | 21 | 22 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/frmt_wms_bluemarble_s3_tms.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://s3.amazonaws.com/com.modestmaps.bluemarble/${z}-r${y}-c${x}.jpg 4 | 5 | 6 | -20037508.34 7 | 20037508.34 8 | 20037508.34 9 | -20037508.34 10 | 9 11 | 1 12 | 1 13 | top 14 | 15 | EPSG:900913 16 | 256 17 | 256 18 | 3 19 | 20 | 21 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/frmt_wms_virtualearth.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://a${server_num}.ortho.tiles.virtualearth.net/tiles/a${quadkey}.jpeg?g=90 4 | 5 | 4 6 | 7 | 8 | -------------------------------------------------------------------------------- /localtileserver/tiler/data/landsat.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/localtileserver/tiler/data/landsat.tif -------------------------------------------------------------------------------- /localtileserver/tiler/data/landsat7.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/localtileserver/tiler/data/landsat7.tif -------------------------------------------------------------------------------- /localtileserver/tiler/data/presidio.wkb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/localtileserver/tiler/data/presidio.wkb -------------------------------------------------------------------------------- /localtileserver/tiler/handler.py: -------------------------------------------------------------------------------- 1 | """Methods for working with images.""" 2 | import json 3 | import pathlib 4 | from typing import Dict, List, Optional, Tuple, Union 5 | 6 | from matplotlib.colors import Colormap, LinearSegmentedColormap, ListedColormap 7 | import numpy as np 8 | import rasterio 9 | from rasterio.enums import ColorInterp 10 | from rio_tiler.colormap import cmap 11 | from rio_tiler.io import Reader 12 | from rio_tiler.models import ImageData 13 | 14 | from .utilities import ImageBytes, get_clean_filename, make_crs 15 | 16 | # Some GDAL options to consider setting: 17 | # - GDAL_ENABLE_WMS_CACHE="YES" 18 | # - GDAL_DEFAULT_WMS_CACHE_PATH=str(get_cache_dir() / "gdalwmscache")) 19 | # - GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR" 20 | # - GDAL_HTTP_UNSAFESSL="YES" 21 | 22 | 23 | def get_reader(path: Union[pathlib.Path, str]) -> Reader: 24 | return Reader(get_clean_filename(path)) 25 | 26 | 27 | def get_meta_data(tile_source: Reader): 28 | info = tile_source.info() 29 | if hasattr(info, "model_dump"): 30 | info = info.model_dump() 31 | else: 32 | info = info.dict() 33 | metadata = { 34 | **info, 35 | **tile_source.dataset.meta, 36 | } 37 | crs = metadata["crs"].to_wkt() if hasattr(metadata["crs"], "to_wkt") else None 38 | metadata.update(crs=crs, transform=list(metadata["transform"])) 39 | if crs: 40 | metadata["bounds"] = get_source_bounds(tile_source) 41 | return metadata 42 | 43 | 44 | def get_source_bounds(tile_source: Reader, projection: str = "EPSG:4326", decimal_places: int = 6): 45 | src_crs = tile_source.dataset.crs 46 | if not src_crs: 47 | return { 48 | "left": -180.0, 49 | "bottom": -90.0, 50 | "right": 180.0, 51 | "top": 90.0, 52 | } 53 | dst_crs = make_crs(projection) 54 | left, bottom, right, top = rasterio.warp.transform_bounds( 55 | src_crs, dst_crs, *tile_source.dataset.bounds 56 | ) 57 | return { 58 | "left": round(left, decimal_places), 59 | "bottom": round(bottom, decimal_places), 60 | "right": round(right, decimal_places), 61 | "top": round(top, decimal_places), 62 | # west, south, east, north 63 | # "west": round(left, decimal_places), 64 | # "south": round(bottom, decimal_places), 65 | # "east": round(right, decimal_places), 66 | # "north": round(top, decimal_places), 67 | } 68 | 69 | 70 | def _handle_band_indexes(tile_source: Reader, indexes: Optional[List[int]] = None): 71 | band_names = [desc[0] for desc in tile_source.info().band_descriptions] 72 | 73 | def _index_lookup(index_or_name: str): 74 | try: 75 | return int(index_or_name) 76 | except ValueError: 77 | pass 78 | try: 79 | return band_names.index(index_or_name) + 1 80 | except ValueError: 81 | pass 82 | raise ValueError(f"Could not find band {index_or_name}") 83 | 84 | if not indexes: 85 | RGB_INTERPRETATIONS = [ColorInterp.red, ColorInterp.green, ColorInterp.blue] 86 | RGB_DESCRIPTORS = ["red", "green", "blue"] 87 | if set(RGB_INTERPRETATIONS).issubset(set(tile_source.dataset.colorinterp)): 88 | indexes = [tile_source.dataset.colorinterp.index(i) + 1 for i in RGB_INTERPRETATIONS] 89 | elif set(RGB_DESCRIPTORS).issubset(set(tile_source.dataset.descriptions)): 90 | indexes = [tile_source.dataset.descriptions.index(i) + 1 for i in RGB_DESCRIPTORS] 91 | elif len(tile_source.dataset.indexes) >= 3: 92 | indexes = [1, 2, 3] 93 | elif len(tile_source.dataset.indexes) < 3: 94 | indexes = [1] 95 | else: 96 | raise ValueError("Could not determine band indexes") 97 | else: 98 | if isinstance(indexes, str): 99 | indexes = int(indexes) 100 | if isinstance(indexes, int): 101 | indexes = [indexes] 102 | if isinstance(indexes, list): 103 | indexes = [_index_lookup(ind) for ind in indexes] 104 | return indexes 105 | 106 | 107 | def _handle_nodata(tile_source: Reader, nodata: Optional[Union[int, float]] = None): 108 | floaty = False 109 | if any(dtype.startswith("float") for dtype in tile_source.dataset.dtypes): 110 | floaty = True 111 | if floaty and nodata is None and tile_source.dataset.nodata is not None: 112 | nodata = np.nan 113 | elif nodata is not None: 114 | if isinstance(nodata, str): 115 | nodata = float(nodata) 116 | return nodata 117 | 118 | 119 | def _handle_vmin_vmax( 120 | indexes: List[int], 121 | vmin: Optional[Union[float, List[float]]] = None, 122 | vmax: Optional[Union[float, List[float]]] = None, 123 | ) -> Tuple[Dict[int, float], Dict[int, float]]: 124 | # TODO: move these string checks to the rest api 125 | if isinstance(vmin, (str, int)): 126 | vmin = float(vmin) 127 | if isinstance(vmax, (str, int)): 128 | vmax = float(vmax) 129 | if isinstance(vmin, list): 130 | vmin = [float(v) for v in vmin] 131 | if isinstance(vmax, list): 132 | vmax = [float(v) for v in vmax] 133 | if isinstance(vmin, float) or vmin is None: 134 | vmin = [vmin] * len(indexes) 135 | if isinstance(vmax, float) or vmax is None: 136 | vmax = [vmax] * len(indexes) 137 | # vmin/vmax must be list of values at this point 138 | if len(vmin) != len(indexes): 139 | raise ValueError("vmin must be same length as indexes") 140 | if len(vmax) != len(indexes): 141 | raise ValueError("vmax must be same length as indexes") 142 | # Now map to the band indexes 143 | return dict(zip(indexes, vmin)), dict(zip(indexes, vmax)) 144 | 145 | 146 | def _render_image( 147 | tile_source: Reader, 148 | img: ImageData, 149 | indexes: List[int], 150 | vmin: Dict[int, Optional[float]], 151 | vmax: Dict[int, Optional[float]], 152 | colormap: Optional[str] = None, 153 | img_format: str = "PNG", 154 | ): 155 | if colormap in cmap.list(): 156 | colormap = cmap.get(colormap) 157 | elif isinstance(colormap, ListedColormap): 158 | c = LinearSegmentedColormap.from_list("", colormap.colors, N=256) 159 | colormap = {k: tuple(v) for k, v in enumerate(c(range(256), 1, 1))} 160 | elif isinstance(colormap, Colormap): 161 | colormap = {k: tuple(v) for k, v in enumerate(colormap(range(256), 1, 1))} 162 | elif colormap: 163 | c = json.loads(colormap) 164 | if isinstance(c, list): 165 | c = LinearSegmentedColormap.from_list("", c, N=256) 166 | colormap = {k: tuple(v) for k, v in enumerate(c(range(256), 1, 1))} 167 | else: 168 | colormap = {} 169 | for key, value in c.items(): 170 | colormap[int(key)] = tuple(value) 171 | 172 | if ( 173 | not colormap 174 | and len(indexes) == 1 175 | and tile_source.dataset.colorinterp[indexes[0] - 1] == ColorInterp.palette 176 | ): 177 | # NOTE: vmin/vmax are not used for palette images 178 | colormap = tile_source.dataset.colormap(indexes[0]) 179 | # TODO: change these to any checks for none in vmin/vmax 180 | elif ( 181 | img.data.dtype != np.dtype("uint8") 182 | or any(v is not None for v in vmin) 183 | or any(v is not None for v in vmax) 184 | ): 185 | stats = tile_source.statistics(indexes=indexes) 186 | in_range = [] 187 | for i in indexes: 188 | in_range.append( 189 | ( 190 | stats[f"b{i}"].min if vmin[i] is None else vmin[i], 191 | stats[f"b{i}"].max if vmax[i] is None else vmax[i], 192 | ) 193 | ) 194 | img.rescale( 195 | in_range=in_range, 196 | out_range=[(0, 255)], 197 | ) 198 | return ImageBytes( 199 | img.render(img_format=img_format, colormap=colormap if colormap else None), 200 | mimetype=f"image/{img_format.lower()}", 201 | ) 202 | 203 | 204 | def get_tile( 205 | tile_source: Reader, 206 | z: int, 207 | x: int, 208 | y: int, 209 | indexes: Optional[List[int]] = None, 210 | colormap: Optional[str] = None, 211 | vmin: Optional[Union[float, List[float]]] = None, 212 | vmax: Optional[Union[float, List[float]]] = None, 213 | nodata: Optional[Union[int, float]] = None, 214 | img_format: str = "PNG", 215 | ): 216 | if colormap is not None and indexes is None: 217 | indexes = [1] 218 | indexes = _handle_band_indexes(tile_source, indexes) 219 | nodata = _handle_nodata(tile_source, nodata) 220 | vmin, vmax = _handle_vmin_vmax(indexes, vmin, vmax) 221 | img = tile_source.tile(x, y, z, indexes=indexes, nodata=nodata) 222 | return _render_image( 223 | tile_source, 224 | img, 225 | indexes=indexes, 226 | vmin=vmin, 227 | vmax=vmax, 228 | colormap=colormap, 229 | img_format=img_format, 230 | ) 231 | 232 | 233 | def get_point( 234 | tile_source: Reader, 235 | lon: float, 236 | lat: float, 237 | **kwargs, 238 | ): 239 | return tile_source.point(lon, lat, **kwargs) 240 | 241 | 242 | def get_preview( 243 | tile_source: Reader, 244 | indexes: Optional[List[int]] = None, 245 | colormap: Optional[str] = None, 246 | vmin: Optional[Union[float, List[float]]] = None, 247 | vmax: Optional[Union[float, List[float]]] = None, 248 | nodata: Optional[Union[int, float]] = None, 249 | img_format: str = "PNG", 250 | max_size: int = 512, 251 | ): 252 | if colormap is not None and indexes is None: 253 | indexes = [1] 254 | indexes = _handle_band_indexes(tile_source, indexes) 255 | nodata = _handle_nodata(tile_source, nodata) 256 | vmin, vmax = _handle_vmin_vmax(indexes, vmin, vmax) 257 | img = tile_source.preview(max_size=max_size, indexes=indexes, nodata=nodata) 258 | return _render_image( 259 | tile_source, 260 | img, 261 | indexes=indexes, 262 | vmin=vmin, 263 | vmax=vmax, 264 | colormap=colormap, 265 | img_format=img_format, 266 | ) 267 | -------------------------------------------------------------------------------- /localtileserver/tiler/palettes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rio_tiler.colormap import cmap as RIO_CMAPS 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def is_rio_cmap(name: str): 9 | """Check whether cmap is supported by rio-tiler.""" 10 | return name in RIO_CMAPS.data.keys() 11 | 12 | 13 | def palette_valid_or_raise(name: str): 14 | if not is_rio_cmap(name): 15 | raise ValueError(f"Please use a valid rio-tiler registered colormap name. Invalid: {name}") 16 | 17 | 18 | def get_palettes(): 19 | """List of available palettes.""" 20 | return {"matplotlib": list(RIO_CMAPS.data.keys())} 21 | -------------------------------------------------------------------------------- /localtileserver/tiler/utilities.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import tempfile 5 | from typing import Optional 6 | from urllib.parse import urlencode, urlparse 7 | 8 | from rasterio import CRS 9 | 10 | from localtileserver.tiler.data import clean_url, get_data_path 11 | 12 | 13 | class ImageBytes(bytes): 14 | """Wrapper class to make repr of image bytes better in ipython.""" 15 | 16 | def __new__(cls, source: bytes, mimetype: str = None): 17 | self = super().__new__(cls, source) 18 | self._mime_type = mimetype 19 | return self 20 | 21 | @property 22 | def mimetype(self): 23 | return self._mime_type 24 | 25 | def _repr_png_(self): 26 | if self.mimetype == "image/png": 27 | return self 28 | 29 | def _repr_jpeg_(self): 30 | if self.mimetype == "image/jpeg": 31 | return self 32 | 33 | def __repr__(self): 34 | if self.mimetype: 35 | return f"ImageBytes<{len(self)}> ({self.mimetype})" 36 | return f"ImageBytes<{len(self)}> (wrapped image bytes)" 37 | 38 | 39 | def get_cache_dir(): 40 | path = pathlib.Path(os.path.join(tempfile.gettempdir(), "localtileserver")) 41 | path.mkdir(parents=True, exist_ok=True) 42 | return path 43 | 44 | 45 | def purge_cache(): 46 | """Completely purge all files from the file cache. 47 | 48 | This should be used with caution, it could delete files that are in use. 49 | """ 50 | cache = get_cache_dir() 51 | shutil.rmtree(cache) 52 | # Return the cache dir so that a fresh directory is created. 53 | return get_cache_dir() 54 | 55 | 56 | def make_vsi(url: str, **options): 57 | url = clean_url(url) 58 | if str(url).startswith("s3://"): 59 | s3_path = url.replace("s3://", "") 60 | vsi = f"/vsis3/{s3_path}" 61 | else: 62 | uoptions = { 63 | "url": str(url), 64 | "use_head": "no", 65 | "list_dir": "no", 66 | } 67 | uoptions.update(options) 68 | vsi = f"/vsicurl?{urlencode(uoptions)}" 69 | return vsi 70 | 71 | 72 | def get_clean_filename(filename: str): 73 | if not filename: 74 | raise OSError("Empty path given") # pragma: no cover 75 | 76 | # Check for example first 77 | if filename == "blue_marble": 78 | filename = get_data_path("frmt_wms_bluemarble_s3_tms.xml") 79 | elif filename == "virtual_earth": 80 | filename = get_data_path("frmt_wms_virtualearth.xml") 81 | elif filename == "arcgis": 82 | filename = get_data_path("frmt_wms_arcgis_mapserver_tms.xml") 83 | elif filename in ["elevation", "dem", "topo"]: 84 | filename = get_data_path("aws_elevation_tiles_prod.xml") 85 | elif filename == "bahamas": 86 | filename = get_data_path("bahamas_rgb.tif") 87 | 88 | if str(filename).startswith("/vsi"): 89 | return filename 90 | parsed = urlparse(str(filename)) 91 | if parsed.scheme in ["http", "https", "s3"]: 92 | return make_vsi(filename) 93 | # Otherwise, treat as local path on Disk 94 | filename = pathlib.Path(filename).expanduser().absolute() 95 | if not filename.exists(): 96 | raise OSError(f"Path does not exist: {filename}") 97 | return filename 98 | 99 | 100 | def format_to_encoding(fmt: Optional[str]) -> str: 101 | """Validate encoding.""" 102 | if not fmt: 103 | return "png" 104 | if fmt.lower() not in ["png", "jpeg", "jpg"]: 105 | raise ValueError(f"Format {fmt!r} is not valid. Try `png` or `jpeg`") 106 | if fmt.lower() == "jpg": 107 | fmt = "jpeg" 108 | return fmt.upper() # PNG, JPEG 109 | 110 | 111 | def make_crs(projection): 112 | if isinstance(projection, str): 113 | return CRS.from_string(projection) 114 | if isinstance(projection, dict): 115 | return CRS.from_dict(projection) 116 | if isinstance(projection, int): 117 | return CRS.from_string(f"EPSG:{projection}") 118 | return CRS(projection) 119 | -------------------------------------------------------------------------------- /localtileserver/utilities.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import re 3 | from urllib.parse import urlencode 4 | 5 | import requests 6 | 7 | from localtileserver.tiler import get_cache_dir 8 | 9 | 10 | def save_file_from_request(response: requests.Response, output_path: pathlib.Path): 11 | d = response.headers["content-disposition"] 12 | fname = re.findall("filename=(.+)", d)[0] 13 | if isinstance(output_path, bool) or not output_path: 14 | output_path = get_cache_dir() / fname 15 | with open(output_path, "wb") as f: 16 | f.write(response.content) 17 | return output_path 18 | 19 | 20 | def add_query_parameters(url: str, params: dict): 21 | if len(params) and "?" not in url: 22 | url += "?" 23 | for k, v in params.items(): 24 | if isinstance(v, (list, tuple)): 25 | for i, sub in enumerate(v): 26 | url += "&" + urlencode({f"{k}.{i}": sub}) 27 | else: 28 | url += "&" + urlencode({k: v}) 29 | return url 30 | -------------------------------------------------------------------------------- /localtileserver/validate.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from rio_cogeo import cog_validate 4 | from rio_tiler.io import Reader 5 | 6 | from localtileserver.client import TilerInterface 7 | from localtileserver.tiler import get_clean_filename 8 | 9 | 10 | def validate_cog( 11 | path: Union[str, Reader, TilerInterface], 12 | strict: bool = True, 13 | quiet: bool = False, 14 | ) -> bool: 15 | if isinstance(path, Reader): 16 | path = path.dataset.name 17 | elif isinstance(path, TilerInterface): 18 | path = path.filename 19 | else: 20 | path = get_clean_filename(path) 21 | return cog_validate(path, strict=strict, quiet=quiet)[0] 22 | -------------------------------------------------------------------------------- /localtileserver/web/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | from localtileserver.web import rest, urls, views 3 | from localtileserver.web.application import create_app, run_app 4 | from localtileserver.web.blueprint import cache, tileserver 5 | -------------------------------------------------------------------------------- /localtileserver/web/application.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import socket 4 | import threading 5 | import webbrowser 6 | 7 | import click 8 | from flask import Flask 9 | from flask_cors import CORS 10 | 11 | from localtileserver.tiler import get_clean_filename 12 | from localtileserver.web.blueprint import cache, tileserver 13 | 14 | 15 | def create_app( 16 | url_prefix: str = "/", cors_all: bool = False, debug: bool = False, cesium_token: str = "" 17 | ): 18 | try: 19 | from localtileserver.web import sentry # noqa: F401 20 | except Exception: 21 | pass 22 | app = Flask(__name__) 23 | if cors_all: 24 | CORS(app, resources={r"/api/*": {"origins": "*"}}) 25 | cache.init_app(app) 26 | app.register_blueprint(tileserver, url_prefix=url_prefix) 27 | app.config.JSONIFY_PRETTYPRINT_REGULAR = True 28 | app.config.SWAGGER_UI_DOC_EXPANSION = "list" 29 | app.config["DEBUG"] = debug 30 | app.config["cesium_token"] = cesium_token 31 | if debug: 32 | logging.getLogger("werkzeug").setLevel(logging.DEBUG) 33 | logging.getLogger("rasterio").setLevel(logging.DEBUG) 34 | logging.getLogger("rio_tiler").setLevel(logging.DEBUG) 35 | return app 36 | 37 | 38 | def run_app( 39 | filename, 40 | port: int = 0, 41 | debug: bool = False, 42 | browser: bool = True, 43 | cesium_token: str = "", 44 | host: str = "127.0.0.1", 45 | cors_all: bool = False, 46 | run: bool = True, 47 | ): 48 | """Serve tiles from the raster at `filename`. 49 | 50 | You can also pass the name of one of the example datasets: `elevation`, 51 | `blue_marble`, `virtual_earth`, `arcgis` or `bahamas`. 52 | 53 | """ 54 | filename = get_clean_filename(filename) 55 | if not str(filename).startswith("/vsi") and not filename.exists(): 56 | raise OSError(f"File does not exist: {filename}") 57 | app = create_app(cors_all=cors_all, debug=debug, cesium_token=cesium_token) 58 | app.config["filename"] = filename 59 | if os.name == "nt" and host == "127.0.0.1": 60 | host = "localhost" 61 | if port == 0: 62 | sock = socket.socket() 63 | sock.bind((host, 0)) 64 | port = sock.getsockname()[1] 65 | sock.close() 66 | if browser: 67 | url = f"http://{host}:{port}?filename={filename}" 68 | threading.Timer(1, lambda: webbrowser.open(url)).start() 69 | if run: 70 | app.run(host=host, port=port, debug=debug) 71 | return app 72 | 73 | 74 | @click.command() 75 | @click.argument("filename") 76 | @click.option("-p", "--port", default=0) 77 | @click.option("-d", "--debug", default=False) 78 | @click.option("-b", "--browser", default=True) 79 | @click.option("-t", "--cesium-token", default="") 80 | @click.option("-h", "--host", default="127.0.0.1") 81 | @click.option("-c", "--cors-all", default=False) 82 | def click_run_app(*args, **kwargs): 83 | return run_app(*args, **kwargs) 84 | -------------------------------------------------------------------------------- /localtileserver/web/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_caching import Cache 3 | 4 | tileserver = Blueprint( 5 | "tileserver", 6 | __name__, 7 | static_folder="static", 8 | static_url_path="/static/tileserver", 9 | template_folder="templates", 10 | ) 11 | 12 | 13 | cache = Cache(config={"CACHE_TYPE": "SimpleCache"}) 14 | -------------------------------------------------------------------------------- /localtileserver/web/rest.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from flask import request, send_file 4 | from flask_restx import Api, Resource as View 5 | from rasterio import RasterioIOError 6 | from rio_tiler.errors import TileOutsideBounds 7 | from werkzeug.exceptions import BadRequest, NotFound, UnsupportedMediaType 8 | 9 | from localtileserver import __version__ 10 | from localtileserver.tiler import ( 11 | format_to_encoding, 12 | get_meta_data, 13 | get_preview, 14 | get_reader, 15 | get_source_bounds, 16 | get_tile, 17 | ) 18 | from localtileserver.tiler.palettes import get_palettes 19 | from localtileserver.web.blueprint import cache, tileserver 20 | from localtileserver.web.utils import ( 21 | get_clean_filename_from_request, 22 | reformat_list_query_parameters, 23 | ) 24 | 25 | REQUEST_CACHE_TIMEOUT = 60 * 60 * 2 26 | 27 | api = Api( 28 | tileserver, 29 | doc="/swagger/", 30 | title="localtileserver", 31 | version=__version__, 32 | default="localtileserver", 33 | default_label="localtileserver namespace", 34 | description="Learn more about localtileserver", 35 | prefix="api", 36 | ) 37 | 38 | BASE_PARAMS = { 39 | "filename": { 40 | "description": "The local path or URL to the image to use.", 41 | "in": "query", 42 | "type": "str", 43 | "example": "https://localtileserver.s3.us-west-2.amazonaws.com/examples/TC_NG_SFBay_US_Geo.tif", 44 | }, 45 | } 46 | STYLE_PARAMS = { 47 | "indexes": { 48 | "description": "The band number(s) to use.", 49 | "in": "query", 50 | "type": "int", # TODO: make this a list 51 | }, 52 | "colormap": { 53 | "description": "The color palette to map the band values (named Matplotlib colormaps). `cmap` is a supported alias.", 54 | "in": "query", 55 | "type": "str", 56 | }, 57 | "vmin": { 58 | "description": "The minimum value for the color mapping.", 59 | "in": "query", 60 | "type": "float", 61 | }, 62 | "vmax": { 63 | "description": "The maximum value for the color mapping.", 64 | "in": "query", 65 | "type": "float", 66 | }, 67 | "nodata": { 68 | "description": "The value to map as no data (often made transparent). Defaults to NaN.", 69 | "in": "query", 70 | "type": "float", 71 | }, 72 | } 73 | 74 | 75 | def make_cache_key(*args, **kwargs): 76 | path = str(request.path) 77 | args = str(hash(frozenset(request.args.items()))) 78 | return path + args 79 | 80 | 81 | class ListPalettes(View): 82 | @cache.cached(timeout=REQUEST_CACHE_TIMEOUT) 83 | def get(self): 84 | return get_palettes() 85 | 86 | 87 | @api.doc(params=BASE_PARAMS) 88 | class BaseImageView(View): 89 | def get_reader(self): 90 | """Return the built tile source.""" 91 | try: 92 | filename = get_clean_filename_from_request() 93 | except OSError as e: 94 | raise BadRequest(str(e)) from e 95 | try: 96 | return get_reader(filename) 97 | except RasterioIOError as e: 98 | raise BadRequest(f"RasterioIOError: {str(e)}") from e 99 | 100 | def get_clean_args(self): 101 | return { 102 | k: v 103 | for k, v in reformat_list_query_parameters(request.args).items() 104 | if k in STYLE_PARAMS 105 | } 106 | 107 | 108 | class ValidateCOGView(BaseImageView): 109 | def get(self): 110 | from localtileserver.validate import validate_cog 111 | 112 | tile_source = self.get_reader() 113 | valid = validate_cog(tile_source, strict=True) 114 | if not valid: 115 | raise UnsupportedMediaType("Not a valid Cloud Optimized GeoTiff.") 116 | return "Valid Cloud Optimized GeoTiff." 117 | 118 | 119 | class MetadataView(BaseImageView): 120 | @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) 121 | def get(self): 122 | tile_source = self.get_reader() 123 | metadata = get_meta_data(tile_source) 124 | metadata["filename"] = str(get_clean_filename_from_request()) 125 | return metadata 126 | 127 | 128 | @api.doc( 129 | params={ 130 | "crs": { 131 | "description": "The projection of the bounds.", 132 | "in": "query", 133 | "type": "str", 134 | "default": "EPSG:4326", 135 | } 136 | } 137 | ) 138 | class BoundsView(BaseImageView): 139 | def get(self): 140 | tile_source = self.get_reader() 141 | bounds = get_source_bounds( 142 | tile_source, 143 | projection=request.args.get("crs", "EPSG:4326"), 144 | ) 145 | bounds["filename"] = str(get_clean_filename_from_request()) 146 | return bounds 147 | 148 | 149 | @api.doc(params=STYLE_PARAMS) 150 | class ThumbnailView(BaseImageView): 151 | @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) 152 | def get(self, format: str = "png"): 153 | try: 154 | encoding = format_to_encoding(format) 155 | except ValueError as e: 156 | raise BadRequest(f"Format {format} is not a valid encoding.") from e 157 | tile_source = self.get_reader() 158 | thumb_data = get_preview(tile_source, img_format=encoding, **self.get_clean_args()) 159 | thumb_data = io.BytesIO(thumb_data) 160 | return send_file( 161 | thumb_data, 162 | download_name=f"thumbnail.{format}", 163 | mimetype=f"image/{format.lower()}", 164 | ) 165 | 166 | 167 | @api.doc(params=STYLE_PARAMS) 168 | class TileView(BaseImageView): 169 | @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) 170 | def get(self, x: int, y: int, z: int, format: str = "png"): 171 | tile_source = self.get_reader() 172 | img_format = format_to_encoding(format) 173 | try: 174 | tile_binary = get_tile( 175 | tile_source, z, x, y, img_format=img_format, **self.get_clean_args() 176 | ) 177 | except TileOutsideBounds as e: 178 | raise NotFound(str(e)) from e 179 | return send_file( 180 | io.BytesIO(tile_binary), 181 | download_name=f"{x}.{y}.{z}.png", 182 | mimetype=f"image/{img_format}", 183 | ) 184 | -------------------------------------------------------------------------------- /localtileserver/web/sentry.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import sentry_sdk 4 | from sentry_sdk.integrations.flask import FlaskIntegration 5 | from werkzeug.exceptions import HTTPException 6 | 7 | sentry_dsn = os.environ.get("SENTRY_DSN", "") 8 | 9 | if sentry_dsn: 10 | sentry_sdk.init( 11 | dsn=sentry_dsn, 12 | integrations=[FlaskIntegration()], 13 | # Set traces_sample_rate to 1.0 to capture 100% 14 | # of transactions for performance monitoring. 15 | # We recommend adjusting this value in production. 16 | traces_sample_rate=1.0, 17 | ignore_errors=[ 18 | HTTPException, 19 | ], 20 | ) 21 | -------------------------------------------------------------------------------- /localtileserver/web/static/js/cesium.js: -------------------------------------------------------------------------------- 1 | /* Stamen's website (http://maps.stamen.com) as of 2019-08-28 says that the 2 | * maps they host may be used free of charge. For http access, use a url like 3 | * http://{s}.tile.stamen.com/toner-lite/{z}/{x}/{y}.png */ 4 | let StamenAttribution = 'Map tiles by Stamen ' + 5 | 'Design, under ' + 6 | 'CC BY 3.0. Data by OpenStreetMap' + 7 | ', under ODbL.'; 8 | 9 | /* Per Carto's website regarding basemap attribution: https://carto.com/help/working-with-data/attribution/#basemaps */ 10 | let CartoAttribution = 'Map tiles by Carto, under CC BY 3.0. Data by OpenStreetMap, under ODbL.' 11 | 12 | // Create ProviderViewModel based on different imagery sources 13 | // - these can be used without Cesium Ion 14 | var imageryViewModels = []; 15 | 16 | // imageryViewModels.push(new Cesium.ProviderViewModel({ 17 | // name: 'OpenStreetMap', 18 | // iconUrl: Cesium.buildModuleUrl('Widgets/Images/ImageryProviders/openStreetMap.png'), 19 | // tooltip: 'OpenStreetMap (OSM) is a collaborative project to create a free editable \ 20 | // map of the world.\nhttp://www.openstreetmap.org', 21 | // creationFunction: function() { 22 | // return new Cesium.UrlTemplateImageryProvider({ 23 | // url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 24 | // subdomains: 'abc', 25 | // minimumLevel: 0, 26 | // maximumLevel: 19 27 | // }); 28 | // } 29 | // })); 30 | imageryViewModels.push(new Cesium.ProviderViewModel({ 31 | name: 'Positron', 32 | tooltip: 'CartoDB Positron basemap', 33 | iconUrl: 'http://a.basemaps.cartocdn.com/light_all/5/15/12.png', 34 | creationFunction: function() { 35 | return new Cesium.UrlTemplateImageryProvider({ 36 | url: 'http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', 37 | credit: CartoAttribution, 38 | minimumLevel: 0, 39 | maximumLevel: 18 40 | }); 41 | } 42 | })); 43 | imageryViewModels.push(new Cesium.ProviderViewModel({ 44 | name: 'Positron without labels', 45 | tooltip: 'CartoDB Positron without labels basemap', 46 | iconUrl: 'http://a.basemaps.cartocdn.com/rastertiles/light_nolabels/5/15/12.png', 47 | creationFunction: function() { 48 | return new Cesium.UrlTemplateImageryProvider({ 49 | url: 'https://{s}.basemaps.cartocdn.com/rastertiles/light_nolabels/{z}/{x}/{y}.png', 50 | credit: CartoAttribution, 51 | minimumLevel: 0, 52 | maximumLevel: 18 53 | }); 54 | } 55 | })); 56 | imageryViewModels.push(new Cesium.ProviderViewModel({ 57 | name: 'Dark Matter', 58 | tooltip: 'CartoDB Dark Matter basemap', 59 | iconUrl: 'http://a.basemaps.cartocdn.com/rastertiles/dark_all/5/15/12.png', 60 | creationFunction: function() { 61 | return new Cesium.UrlTemplateImageryProvider({ 62 | url: 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}.png', 63 | credit: CartoAttribution, 64 | minimumLevel: 0, 65 | maximumLevel: 18 66 | }); 67 | } 68 | })); 69 | imageryViewModels.push(new Cesium.ProviderViewModel({ 70 | name: 'Dark Matter without labels', 71 | tooltip: 'CartoDB Dark Matter without labels basemap', 72 | iconUrl: 'http://a.basemaps.cartocdn.com/rastertiles/dark_nolabels/5/15/12.png', 73 | creationFunction: function() { 74 | return new Cesium.UrlTemplateImageryProvider({ 75 | url: 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_nolabels/{z}/{x}/{y}.png', 76 | credit: CartoAttribution, 77 | minimumLevel: 0, 78 | maximumLevel: 18 79 | }); 80 | } 81 | })); 82 | imageryViewModels.push(new Cesium.ProviderViewModel({ 83 | name: 'Voyager', 84 | tooltip: 'CartoDB Voyager basemap', 85 | iconUrl: 'http://a.basemaps.cartocdn.com/rastertiles/voyager_labels_under/5/15/12.png', 86 | creationFunction: function() { 87 | return new Cesium.UrlTemplateImageryProvider({ 88 | url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager_labels_under/{z}/{x}/{y}.png', 89 | credit: CartoAttribution, 90 | minimumLevel: 0, 91 | maximumLevel: 18 92 | }); 93 | } 94 | })); 95 | imageryViewModels.push(new Cesium.ProviderViewModel({ 96 | name: 'Voyager without labels', 97 | tooltip: 'CartoDB Voyager without labels basemap', 98 | iconUrl: 'http://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/5/15/12.png', 99 | creationFunction: function() { 100 | return new Cesium.UrlTemplateImageryProvider({ 101 | url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}.png', 102 | credit: CartoAttribution, 103 | minimumLevel: 0, 104 | maximumLevel: 18 105 | }); 106 | } 107 | })); 108 | imageryViewModels.push(new Cesium.ProviderViewModel({ 109 | name: 'National Map Satellite', 110 | iconUrl: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/4/6/4', 111 | creationFunction: function() { 112 | return new Cesium.UrlTemplateImageryProvider({ 113 | url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}', 114 | credit: 'Tile data from USGS', 115 | minimumLevel: 0, 116 | maximumLevel: 16 117 | }); 118 | } 119 | })); 120 | 121 | // Initialize the viewer - this works without a token! 122 | viewer = new Cesium.Viewer('cesiumContainer', { 123 | imageryProviderViewModels: imageryViewModels, 124 | selectedImageryProviderViewModel: imageryViewModels[0], 125 | animation: false, 126 | timeline: false, 127 | infoBox: false, 128 | geocoder: false, 129 | fullscreenButton: false, 130 | selectionIndicator: false, 131 | terrainProvider: undefined, //Cesium.Ion.defaultAccessToken ? Cesium.createWorldTerrain() : undefined, 132 | navigationInstructionsInitiallyVisible: false, 133 | }); 134 | viewer.scene.fog.enabled = false; 135 | 136 | 137 | // if no token, remove terrain 138 | if (Cesium.Ion.defaultAccessToken === undefined) { 139 | // Remove the Terrain section of the baseLayerPicker 140 | viewer.baseLayerPicker.viewModel.terrainProviderViewModels.removeAll() 141 | } 142 | -------------------------------------------------------------------------------- /localtileserver/web/static/styles/cesium.css: -------------------------------------------------------------------------------- 1 | .app .data .map { 2 | position: relative; 3 | } 4 | 5 | #cesiumContainer { 6 | width: 100%; 7 | height: 100%; 8 | margin: 0; 9 | padding: 0; 10 | position: absolute; 11 | z-index: 10; 12 | } 13 | 14 | .slidecontainer { 15 | background-color: #DCDCDC; 16 | padding: 2px; 17 | border: 1px solid #000; 18 | text-align: left; 19 | border-radius: 10px; 20 | position: absolute; 21 | opacity: 0.75; 22 | z-index: 11; 23 | bottom: 5px; 24 | right: 5px; 25 | } 26 | -------------------------------------------------------------------------------- /localtileserver/web/static/styles/snackbar.css: -------------------------------------------------------------------------------- 1 | /* The snackbar - position it at the bottom and in the middle of the screen */ 2 | #snackbar { 3 | visibility: hidden; 4 | /* Hidden by default. Visible on click */ 5 | min-width: 250px; 6 | /* Set a default minimum width */ 7 | margin-left: -125px; 8 | /* Divide value of min-width by 2 */ 9 | background-color: #333; 10 | /* Black background color */ 11 | color: #fff; 12 | /* White text color */ 13 | text-align: center; 14 | /* Centered text */ 15 | border-radius: 2px; 16 | /* Rounded borders */ 17 | padding: 16px; 18 | /* Padding */ 19 | position: fixed; 20 | /* Sit on top of the screen */ 21 | z-index: 1; 22 | /* Add a z-index if needed */ 23 | left: 50%; 24 | /* Center the snackbar */ 25 | bottom: 30px; 26 | /* 30px from the bottom */ 27 | } 28 | 29 | /* Show the snackbar when clicking on a button (class added with JavaScript) */ 30 | #snackbar.show { 31 | visibility: visible; 32 | /* Show the snackbar */ 33 | /* Add animation: Take 0.5 seconds to fade in and out the snackbar. 34 | However, delay the fade out process for 2.5 seconds */ 35 | -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s; 36 | animation: fadein 0.5s, fadeout 0.5s 2.5s; 37 | } 38 | 39 | /* Animations to fade the snackbar in and out */ 40 | @-webkit-keyframes fadein { 41 | from { 42 | bottom: 0; 43 | opacity: 0; 44 | } 45 | 46 | to { 47 | bottom: 30px; 48 | opacity: 1; 49 | } 50 | } 51 | 52 | @keyframes fadein { 53 | from { 54 | bottom: 0; 55 | opacity: 0; 56 | } 57 | 58 | to { 59 | bottom: 30px; 60 | opacity: 1; 61 | } 62 | } 63 | 64 | @-webkit-keyframes fadeout { 65 | from { 66 | bottom: 30px; 67 | opacity: 1; 68 | } 69 | 70 | to { 71 | bottom: 0; 72 | opacity: 0; 73 | } 74 | } 75 | 76 | @keyframes fadeout { 77 | from { 78 | bottom: 30px; 79 | opacity: 1; 80 | } 81 | 82 | to { 83 | bottom: 0; 84 | opacity: 0; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /localtileserver/web/static/styles/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | #wrapper { 9 | padding: 0 0; 10 | position: absolute; 11 | top: 50px; 12 | bottom: 0; 13 | left: 0; 14 | right: 0; 15 | } 16 | 17 | #content { 18 | min-height: 100%; 19 | } 20 | 21 | .navbar { 22 | margin-top: -50px; 23 | height: 50px; 24 | z-index: 100; 25 | background-color: #DCDCDC; 26 | } 27 | 28 | .offcanvas-header { 29 | display: none; 30 | } 31 | 32 | @media (max-width: 767px) { 33 | .offcanvas-header { 34 | display: block; 35 | } 36 | 37 | .navbar-collapse { 38 | position: fixed; 39 | top: 0; 40 | bottom: 0; 41 | left: 100%; 42 | width: 100%; 43 | padding-right: 1rem; 44 | padding-left: 1rem; 45 | overflow-y: auto; 46 | visibility: hidden; 47 | background-color: #DCDCDC; 48 | transition: visibility .2s ease-in-out, -webkit-transform .2s ease-in-out; 49 | } 50 | 51 | .navbar-collapse.show { 52 | visibility: visible; 53 | transform: translateX(-100%); 54 | } 55 | } 56 | 57 | p { 58 | margin: 0; 59 | padding: 0 0 1em 0; 60 | } 61 | 62 | .sm-icons { 63 | flex-direction: row; 64 | } 65 | 66 | @media only screen and (max-width: 767px) { 67 | .sm-icons .nav-item { 68 | padding-right: 1em; 69 | } 70 | } 71 | 72 | ul { 73 | text-align: center; 74 | list-style-position: inside; 75 | } 76 | 77 | 78 | .examplescontainer { 79 | background-color: #DCDCDC; 80 | padding: 2px; 81 | border: 1px solid #000; 82 | text-align: left; 83 | border-radius: 10px; 84 | position: absolute; 85 | opacity: 0.75; 86 | z-index: 11; 87 | top: 5px; 88 | left: 5px; 89 | } 90 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/404file.html: -------------------------------------------------------------------------------- 1 | {% extends "tileserver/base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Raster file cannot be opened

8 |

We are unable to open the file
resource you provided:
{{ filename }}

9 | 12 |
13 |
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/_include/cesium.html: -------------------------------------------------------------------------------- 1 | {% block cesium %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 19 | 20 | 21 | 22 | 23 | 24 | {% endblock cesium %} 25 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/_include/examples.html: -------------------------------------------------------------------------------- 1 | {% block examples %} 2 |
3 | 4 | 15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/_include/palettes.html: -------------------------------------------------------------------------------- 1 | {% block colormaps %} 2 | 3 | 4 | 7 |
8 | 26 | 112 | 113 | {% endblock %} 114 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% if google_analytics_mid %} 17 | 18 | 19 | 29 | {% endif %} 30 | 31 | 32 | 33 | 34 | 92 | 93 | Tile Server 94 | 95 | 96 | 97 |
98 | 128 | 129 | 142 | 143 |
144 | {% block content %} {% endblock %} 145 |
146 | 147 | 159 |
160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/baseTileViewer.html: -------------------------------------------------------------------------------- 1 | {% extends "tileserver/base.html" %} 2 | 3 | {% block content %} 4 | 5 | 46 | 47 | {% block tileViewer %} 48 | {% endblock %} 49 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/cesiumSplitViewer.html: -------------------------------------------------------------------------------- 1 | {% extends "tileserver/base.html" %} 2 | 3 | {% block content %} 4 | 5 | {% include 'tileserver/_include/cesium.html' %} 6 | 7 |
8 | 9 | 10 |
11 | 12 | 27 | 28 | 129 | 130 | {% endblock %} 131 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/cesiumViewer.html: -------------------------------------------------------------------------------- 1 | {% extends "tileserver/baseTileViewer.html" %} 2 | 3 | {% block tileViewer %} 4 | 5 | {% include 'tileserver/_include/cesium.html' %} 6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | {% include 'tileserver/_include/examples.html' %} 16 |
17 | 18 | 51 | 52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /localtileserver/web/templates/tileserver/splitForm.html: -------------------------------------------------------------------------------- 1 | {% extends "tileserver/base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Comparison

8 |

Enter the two files you would like to compare

9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 |
17 |
18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /localtileserver/web/urls.py: -------------------------------------------------------------------------------- 1 | from localtileserver.web import rest, views 2 | from localtileserver.web.blueprint import tileserver 3 | 4 | # Views/pages 5 | tileserver.add_url_rule("/", view_func=views.CesiumViewer.as_view("index")) 6 | tileserver.add_url_rule("/split/", view_func=views.CesiumSplitViewer.as_view("split")) 7 | tileserver.add_url_rule("/split/form/", view_func=views.SplitViewForm.as_view("split-form")) 8 | 9 | # REST endpoints 10 | rest.api.add_resource( 11 | rest.ThumbnailView, 12 | "/thumbnail.", 13 | endpoint="thumbnail", 14 | ) 15 | rest.api.add_resource( 16 | rest.MetadataView, 17 | "/metadata", 18 | endpoint="metadata", 19 | ) 20 | rest.api.add_resource( 21 | rest.BoundsView, 22 | "/bounds", 23 | endpoint="bounds", 24 | ) 25 | rest.api.add_resource( 26 | rest.TileView, 27 | "/tiles///.", 28 | endpoint="tiles", 29 | ) 30 | rest.api.add_resource( 31 | rest.ListPalettes, 32 | "/palettes", 33 | endpoint="palettes", 34 | ) 35 | rest.api.add_resource( 36 | rest.ValidateCOGView, 37 | "/validate", 38 | endpoint="validate", 39 | ) 40 | -------------------------------------------------------------------------------- /localtileserver/web/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import current_app, request 4 | 5 | from localtileserver.tiler import get_clean_filename 6 | from localtileserver.tiler.data import get_sf_bay_url 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def get_clean_filename_from_request(param_name: str = "filename", strict: bool = False): 12 | try: 13 | # First look for filename in URL params 14 | f = request.args.get(param_name) 15 | if not f: 16 | raise KeyError 17 | filename = get_clean_filename(f) 18 | except KeyError: 19 | # Backup to app.config 20 | try: 21 | filename = get_clean_filename(current_app.config[param_name]) 22 | except KeyError: 23 | message = "No filename set in app config or URL params. Using sample data." 24 | if strict: 25 | raise OSError(message) 26 | # Fallback to sample data 27 | logger.error(message) 28 | filename = get_clean_filename(get_sf_bay_url()) 29 | return filename 30 | 31 | 32 | def reformat_list_query_parameters(args: dict): 33 | out = {} 34 | for k, v in args.items(): 35 | name = k.split(".")[0] 36 | if name in out: 37 | out[name].append(v) 38 | else: 39 | out.setdefault(name, [v]) 40 | # If not multiple, remove list 41 | for k, v in out.items(): 42 | if len(v) == 1: 43 | out[k] = v[0] 44 | return out 45 | -------------------------------------------------------------------------------- /localtileserver/web/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from flask import current_app, render_template, request 5 | from flask.views import View 6 | from rasterio.errors import RasterioIOError 7 | 8 | from localtileserver.tiler import data 9 | from localtileserver.tiler.handler import get_meta_data, get_reader, get_source_bounds 10 | from localtileserver.web.blueprint import tileserver 11 | from localtileserver.web.utils import get_clean_filename_from_request 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class BaseViewer(View): 17 | def render_or_404(self, template: str): 18 | """Check the file in the arguments and 404 if invalid.""" 19 | try: 20 | filename = get_clean_filename_from_request() 21 | _ = get_reader(filename) 22 | except (OSError, AttributeError, RasterioIOError): 23 | return render_template("tileserver/404file.html"), 404 24 | return render_template(template) 25 | 26 | 27 | class CesiumViewer(BaseViewer): 28 | def dispatch_request(self): 29 | return self.render_or_404("tileserver/cesiumViewer.html") 30 | 31 | 32 | class CesiumSplitViewer(View): 33 | def dispatch_request(self): 34 | try: 35 | filename = get_clean_filename_from_request("filenameA", strict=True) 36 | _ = get_reader(filename) 37 | except (OSError, AttributeError, RasterioIOError): 38 | f = request.args.get("filenameA") 39 | return render_template("tileserver/404file.html", filename=f), 404 40 | try: 41 | filename = get_clean_filename_from_request("filenameB", strict=True) 42 | _ = get_reader(filename) 43 | except (OSError, AttributeError, RasterioIOError): 44 | f = request.args.get("filenameB") 45 | return render_template("tileserver/404file.html", filename=f), 404 46 | return render_template("tileserver/cesiumSplitViewer.html") 47 | 48 | 49 | class SplitViewForm(View): 50 | def dispatch_request(self): 51 | return render_template("tileserver/splitForm.html") 52 | 53 | 54 | @tileserver.context_processor 55 | def raster_context(): 56 | try: 57 | filename = get_clean_filename_from_request() 58 | except OSError: 59 | filename = request.args.get("filename", "") 60 | context = {} 61 | context["filename"] = str(filename) 62 | try: 63 | tile_source = get_reader(filename) 64 | except (OSError, AttributeError, RasterioIOError): 65 | return context 66 | context.update(get_meta_data(tile_source)) 67 | context["bounds"] = get_source_bounds(tile_source, projection="EPSG:4326") 68 | return context 69 | 70 | 71 | @tileserver.context_processor 72 | def sample_data_context(): 73 | context = {} 74 | context["filename_dem"] = data.get_data_path("aws_elevation_tiles_prod.xml") 75 | context["filename_bluemarble"] = data.get_data_path("frmt_wms_bluemarble_s3_tms.xml") 76 | context["filename_virtualearth"] = data.get_data_path("frmt_wms_virtualearth.xml") 77 | context["filename_sf_bay"] = data.get_sf_bay_url() 78 | context["filename_landsat_salt_lake"] = data.get_data_path("landsat.tif") 79 | context["filename_oam2"] = data.get_oam2_url() 80 | context["cesium_token"] = current_app.config.get("cesium_token", "") 81 | return context 82 | 83 | 84 | @tileserver.context_processor 85 | def google_analytics_context(): 86 | context = {} 87 | mid = os.environ.get("GOOGLE_ANALYTICS_MID", "") 88 | if mid: 89 | context["google_analytics_mid"] = mid 90 | return context 91 | -------------------------------------------------------------------------------- /localtileserver/web/wsgi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from localtileserver.web import create_app 4 | 5 | logging.getLogger("werkzeug").setLevel(logging.DEBUG) 6 | logging.getLogger("rasterio").setLevel(logging.DEBUG) 7 | logging.getLogger("rio_tiler").setLevel(logging.DEBUG) 8 | 9 | app = create_app() 10 | -------------------------------------------------------------------------------- /localtileserver/widgets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | from typing import List, Optional, Union 4 | import warnings 5 | 6 | from matplotlib.colors import Colormap 7 | import rasterio 8 | 9 | from localtileserver.client import TileClient, get_or_create_tile_client 10 | 11 | logger = logging.getLogger(__name__) 12 | DEFAULT_ATTRIBUTION = "Raster file served by localtileserver." 13 | 14 | 15 | class LocalTileServerLayerMixin: 16 | """Mixin class for tile layers using localtileserver.""" 17 | 18 | # Prevent the client from being garbage collected 19 | tile_server: TileClient 20 | 21 | 22 | def get_leaflet_tile_layer( 23 | source: Union[pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase], 24 | port: Union[int, str] = "default", 25 | debug: bool = False, 26 | indexes: Optional[List[int]] = None, 27 | colormap: Optional[Union[str, Colormap, List[str]]] = None, 28 | vmin: Optional[Union[float, List[float]]] = None, 29 | vmax: Optional[Union[float, List[float]]] = None, 30 | nodata: Optional[Union[int, float]] = None, 31 | attribution: str = None, 32 | **kwargs, 33 | ): 34 | """Generate an ipyleaflet TileLayer for the given TileClient. 35 | 36 | Parameters 37 | ---------- 38 | source : Union[pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase] 39 | The source of the tile layer. This can be a path on disk or an already 40 | open ``TileClient`` 41 | port : int 42 | The port on your host machine to use for the tile server (if creating 43 | a tileserver. This is ignored if a file path is given). This defaults 44 | to getting an available port. 45 | debug : bool 46 | Run the tile server in debug mode (if creating a tileserver. This is 47 | ignored if a file path is given). 48 | indexes : int 49 | The band of the source raster to use (default if None is to show RGB if 50 | available). Band indexing starts at 1. This can also be a list of 51 | integers to set which 3 bands to use for RGB. 52 | colormap : str 53 | The name of the matplotlib colormap to use when plotting a single band. 54 | Default is greyscale. 55 | vmin : float 56 | The minimum value to use when colormapping a single band. 57 | vmax : float 58 | The maximized value to use when colormapping a single band. 59 | nodata : float 60 | The value from the band to use to interpret as not valid data. 61 | attribution : str 62 | Attribution for the source raster. This 63 | defaults to a message about it being a local file. 64 | **kwargs 65 | All additional keyword arguments are passed to ``ipyleaflet.TileLayer``. 66 | 67 | Return 68 | ------ 69 | ipyleaflet.TileLayer 70 | 71 | """ 72 | # Safely import ipyleaflet 73 | try: 74 | from ipyleaflet import TileLayer 75 | from traitlets import Tuple, Union 76 | except ImportError as e: # pragma: no cover 77 | raise ImportError(f"Please install `ipyleaflet`: {e}") from e 78 | 79 | if "band" in kwargs: 80 | indexes = kwargs.pop("band") 81 | warnings.warn( 82 | "The `band` keyword argument is deprecated. Please use `indexes` instead.", 83 | ) 84 | elif "bands" in kwargs: 85 | indexes = kwargs.pop("bands") 86 | warnings.warn( 87 | "The `bands` keyword argument is deprecated. Please use `indexes` instead.", 88 | ) 89 | if "cmap" in kwargs: 90 | colormap = kwargs.pop("cmap") 91 | warnings.warn( 92 | "The `cmap` keyword argument is deprecated. Please use `colormap` instead.", 93 | ) 94 | 95 | class BoundTileLayer(TileLayer, LocalTileServerLayerMixin): 96 | # https://github.com/jupyter-widgets/ipyleaflet/issues/888 97 | # https://github.com/ipython/traitlets/issues/626#issuecomment-699957829 98 | bounds = Union((Tuple(),), default_value=None, allow_none=True).tag(sync=True, o=True) 99 | 100 | source, created = get_or_create_tile_client( 101 | source, 102 | port=port, 103 | debug=debug, 104 | ) 105 | url = source.get_tile_url( 106 | indexes=indexes, 107 | colormap=colormap, 108 | vmin=vmin, 109 | vmax=vmax, 110 | nodata=nodata, 111 | client=True, 112 | ) 113 | if attribution is None: 114 | attribution = DEFAULT_ATTRIBUTION 115 | kwargs.setdefault("max_native_zoom", 30) # source.max_zoom) 116 | kwargs.setdefault("max_zoom", 30) # source.max_zoom) 117 | kwargs.setdefault("show_loading", True) 118 | b = source.bounds() 119 | bounds = ((b[0], b[2]), (b[1], b[3])) 120 | tile_layer = BoundTileLayer(url=url, attribution=attribution, bounds=bounds, **kwargs) 121 | if created: 122 | # HACK: Prevent the client from being garbage collected 123 | tile_layer.tile_server = source 124 | return tile_layer 125 | 126 | 127 | def get_folium_tile_layer( 128 | source: Union[pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase], 129 | port: Union[int, str] = "default", 130 | debug: bool = False, 131 | indexes: Optional[List[int]] = None, 132 | colormap: Optional[str] = None, 133 | vmin: Optional[Union[float, List[float]]] = None, 134 | vmax: Optional[Union[float, List[float]]] = None, 135 | nodata: Optional[Union[int, float]] = None, 136 | attr: str = None, 137 | **kwargs, 138 | ): 139 | """Generate a folium TileLayer for the given TileClient. 140 | 141 | Parameters 142 | ---------- 143 | source : Union[pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase] 144 | The source of the tile layer. This can be a path on disk or an already 145 | open ``TileClient`` 146 | port : int 147 | The port on your host machine to use for the tile server (if creating 148 | a tileserver. This is ignored if a file path is given). This defaults 149 | to getting an available port. 150 | debug : bool 151 | Run the tile server in debug mode (if creating a tileserver. This is 152 | ignored if a file path is given). 153 | indexes : int 154 | The band of the source raster to use (default if None is to show RGB if 155 | available). Band indexing starts at 1. This can also be a list of 156 | integers to set which 3 bands to use for RGB. 157 | colormap : str 158 | The name of the matplotlib colormap to use when plotting a single band. 159 | Default is greyscale. 160 | vmin : float 161 | The minimum value to use when colormapping a single band. 162 | vmax : float 163 | The maximized value to use when colormapping a single band. 164 | nodata : float 165 | The value from the band to use to interpret as not valid data. 166 | attr : str 167 | Folium requires the custom tile source have an attribution. This 168 | defaults to a message about it being a local file. 169 | **kwargs 170 | All additional keyword arguments are passed to ``folium.TileLayer``. 171 | 172 | Return 173 | ------ 174 | folium.TileLayer 175 | 176 | """ 177 | # Safely import folium 178 | try: 179 | from folium import TileLayer 180 | except ImportError as e: # pragma: no cover 181 | raise ImportError(f"Please install `folium`: {e}") 182 | 183 | if "band" in kwargs: 184 | indexes = kwargs.pop("band") 185 | warnings.warn( 186 | "The `band` keyword argument is deprecated. Please use `indexes` instead.", 187 | ) 188 | elif "bands" in kwargs: 189 | indexes = kwargs.pop("bands") 190 | warnings.warn( 191 | "The `bands` keyword argument is deprecated. Please use `indexes` instead.", 192 | ) 193 | if "cmap" in kwargs: 194 | colormap = kwargs.pop("cmap") 195 | warnings.warn( 196 | "The `cmap` keyword argument is deprecated. Please use `colormap` instead.", 197 | ) 198 | 199 | class FoliumTileLayer(TileLayer, LocalTileServerLayerMixin): 200 | pass 201 | 202 | source, created = get_or_create_tile_client( 203 | source, 204 | port=port, 205 | debug=debug, 206 | ) 207 | url = source.get_tile_url( 208 | indexes=indexes, 209 | colormap=colormap, 210 | vmin=vmin, 211 | vmax=vmax, 212 | nodata=nodata, 213 | client=True, 214 | ) 215 | if attr is None: 216 | attr = DEFAULT_ATTRIBUTION 217 | b = source.bounds() 218 | bounds = ((b[0], b[2]), (b[1], b[3])) 219 | tile_layer = FoliumTileLayer(tiles=url, bounds=bounds, attr=attr, **kwargs) 220 | if created: 221 | # HACK: Prevent the client from being garbage collected 222 | tile_layer.tile_server = source 223 | return tile_layer 224 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools'] 3 | build-backend = 'setuptools.build_meta' 4 | 5 | [project] 6 | name = "localtileserver" 7 | version = "0.10.6" 8 | description = "Locally serve geospatial raster tiles in the Slippy Map standard." 9 | readme = "README.md" 10 | authors = [ 11 | {name = "Bane Sullivan", email = "hello@banesullivan.com"}, 12 | ] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "License :: OSI Approved :: MIT License", 16 | "Intended Audience :: Science/Research", 17 | "Topic :: Scientific/Engineering :: Information Analysis", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | requires-python = '>=3.8' 26 | dependencies = [ 27 | "click", 28 | "flask>=2.0.0,<4", 29 | "Flask-Caching", 30 | "flask-cors", 31 | "flask-restx>=1.3.0", 32 | "rio-tiler", 33 | "rio-cogeo", 34 | "requests", 35 | "server-thread", 36 | "scooby", 37 | "werkzeug", 38 | ] 39 | 40 | [project.optional-dependencies] 41 | colormaps = [ 42 | "matplotlib", 43 | "cmocean", 44 | "colorcet", 45 | ] 46 | jupyter = [ 47 | "jupyter-server-proxy", 48 | "ipyleaflet", 49 | ] 50 | helpers = [ 51 | "shapely", 52 | ] 53 | 54 | [tool.setuptools] 55 | include-package-data = true 56 | 57 | [tool.setuptools.packages.find] 58 | include = [ 59 | 'localtileserver', 60 | 'localtileserver.*', 61 | ] 62 | 63 | [project.urls] 64 | Documentation = 'https://localtileserver.banesullivan.com' 65 | "Bug Tracker" = 'https://github.com/banesullivan/localtileserver/issues' 66 | "Source Code" = 'https://github.com/banesullivan/localtileserver' 67 | 68 | [project.scripts] 69 | localtileserver = "localtileserver.__main__:run_app" 70 | 71 | [tool.black] 72 | line-length = 100 73 | skip-string-normalization = false 74 | target-version = ["py38"] 75 | exclude='\.eggs|\.git|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|node_modules' 76 | 77 | [tool.isort] 78 | profile = "black" 79 | line_length = 100 80 | # Sort by name, don't cluster "from" vs "import" 81 | force_sort_within_sections = true 82 | # Combines "as" imports on the same line 83 | combine_as_imports = true 84 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | flask>=2.0.0,<4 3 | Flask-Caching 4 | flask-cors 5 | flask-restx>=0.5.0 6 | gunicorn 7 | matplotlib 8 | pytest 9 | pytest-cov 10 | rasterio 11 | requests 12 | rio-cogeo 13 | rio-tiler 14 | scooby 15 | sentry-sdk[flask] 16 | server-thread 17 | werkzeug 18 | -------------------------------------------------------------------------------- /requirements_doc.txt: -------------------------------------------------------------------------------- 1 | bokeh 2 | jupyter-sphinx 3 | pydata-sphinx-theme==0.16.1 4 | sphinx==7.3.7 5 | sphinx-copybutton 6 | sphinx-notfound-page 7 | -------------------------------------------------------------------------------- /requirements_jupyter.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | folium 3 | ipyleaflet 4 | jupyter-server-proxy 5 | shapely 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """See pyproject.toml for project metadata.""" 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | generated/ 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/__init__.py -------------------------------------------------------------------------------- /tests/baseline/test_custom_colormap[colormap0-None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_custom_colormap[colormap0-None].png -------------------------------------------------------------------------------- /tests/baseline/test_custom_colormap[colormap1-2].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_custom_colormap[colormap1-2].png -------------------------------------------------------------------------------- /tests/baseline/test_landsat7_nodata[0].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_landsat7_nodata[0].png -------------------------------------------------------------------------------- /tests/baseline/test_landsat7_nodata[255].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_landsat7_nodata[255].png -------------------------------------------------------------------------------- /tests/baseline/test_landsat7_nodata[None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_landsat7_nodata[None].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail.png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_colormap[inferno-1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_colormap[inferno-1].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_colormap[viridis-1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_colormap[viridis-1].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_colormap[viridis-None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_colormap[viridis-None].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_nodata[0].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_nodata[0].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_nodata[255].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_nodata[255].png -------------------------------------------------------------------------------- /tests/baseline/test_thumbnail_nodata[None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_thumbnail_nodata[None].png -------------------------------------------------------------------------------- /tests/baseline/test_tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile.png -------------------------------------------------------------------------------- /tests/baseline/test_tile_colormap[None-None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_colormap[None-None].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_colormap[inferno-1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_colormap[inferno-1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_colormap[viridis-1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_colormap[viridis-1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_colormap[viridis-None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_colormap[viridis-None].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_indexes[1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_indexes[1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_indexes[2].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_indexes[2].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_indexes[3].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_indexes[3].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_indexes[indexes0].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_indexes[indexes0].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_indexes[indexes1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_indexes[indexes1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_nodata[0].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_nodata[0].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_nodata[255].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_nodata[255].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_nodata[None].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_nodata[None].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_vmax[100].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_vmax[100].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_vmax[vmax1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_vmax[vmax1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_vmin[100].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_vmin[100].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_vmin[vmin1].png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_vmin[vmin1].png -------------------------------------------------------------------------------- /tests/baseline/test_tile_vmin_vmax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/banesullivan/localtileserver/d9625035607b4992c86c4126d4f8b6d254c5d369/tests/baseline/test_tile_vmin_vmax.png -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from matplotlib.testing.compare import compare_images 4 | import pytest 5 | import rasterio 6 | 7 | from localtileserver.examples import get_bahamas, get_blue_marble, get_data_path, get_landsat7 8 | from localtileserver.web import create_app 9 | 10 | 11 | @pytest.fixture 12 | def flask_client(): 13 | app = create_app() 14 | with app.test_client() as f_client: 15 | yield f_client 16 | 17 | 18 | @pytest.fixture 19 | def bahamas_file(): 20 | return get_data_path("bahamas_rgb.tif") 21 | 22 | 23 | @pytest.fixture 24 | def bahamas(port="default", debug=True): 25 | # Using debug True since in a testing environment 26 | client = get_bahamas(port=port, debug=debug) 27 | yield client 28 | client.shutdown(force=True) 29 | 30 | 31 | @pytest.fixture 32 | def landsat7(port="default", debug=True): 33 | # Using debug True since in a testing environment 34 | client = get_landsat7(port=port, debug=debug) 35 | yield client 36 | client.shutdown(force=True) 37 | 38 | 39 | @pytest.fixture 40 | def blue_marble(port="default", debug=True): 41 | # Using debug True since in a testing environment 42 | client = get_blue_marble(port=port, debug=debug) 43 | yield client 44 | client.shutdown(force=True) 45 | 46 | 47 | @pytest.fixture 48 | def remote_file_url(): 49 | return "https://github.com/giswqs/data/raw/main/raster/landsat7.tif" 50 | 51 | 52 | @pytest.fixture 53 | def remote_file_s3(): 54 | with rasterio.Env(GDAL_PAM_ENABLED="NO", AWS_NO_SIGN_REQUEST="YES"): 55 | yield "s3://sentinel-cogs/sentinel-s2-l2a-cogs/2020/S2A_34JCL_20200309_0_L2A/B01.tif" 56 | 57 | 58 | @pytest.fixture 59 | def compare(request): 60 | calling_function_name = request.node.name 61 | 62 | def _compare(image: bytes): 63 | path = Path(__file__).parent / "baseline" 64 | gen = Path(__file__).parent / "generated" 65 | path.mkdir(exist_ok=True) 66 | gen.mkdir(exist_ok=True) 67 | filename = f"{calling_function_name}.png" 68 | if not (path / filename).exists(): 69 | with open(path / filename, "wb") as f: 70 | f.write(image) 71 | else: 72 | with open(gen / filename, "wb") as f: 73 | f.write(image) 74 | result = compare_images(path / filename, gen / filename, 0.1, in_decorator=True) 75 | assert ( 76 | result is None 77 | ), f"Image comparison failed with RMS {result['rms']}. Difference in {result['diff']}" 78 | 79 | return _compare 80 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from localtileserver.web.application import run_app 4 | 5 | 6 | def test_home_page_with_file(bahamas): 7 | r = requests.get(bahamas.server_base_url) 8 | r.raise_for_status() 9 | 10 | 11 | def test_home_page(flask_client): 12 | r = flask_client.get("/") 13 | assert r.status_code == 200 14 | r = flask_client.get("/?filename=foobar") 15 | assert r.status_code == 404 16 | 17 | 18 | def test_cesium_split_view(flask_client): 19 | filenameA = "https://github.com/giswqs/data/raw/main/raster/landsat7.tif" 20 | filenameB = filenameA 21 | r = flask_client.get(f"/split/?filenameA={filenameA}&filenameB={filenameB}") 22 | assert r.status_code == 200 23 | r = flask_client.get(f"/split/?filenameA={filenameA}") 24 | assert r.status_code == 404 25 | r = flask_client.get(f"/split/?filenameB={filenameB}") 26 | assert r.status_code == 404 27 | r = flask_client.get("/split/form/") 28 | assert r.status_code == 200 29 | 30 | 31 | def test_style(flask_client): 32 | r = flask_client.get("/api/thumbnail.png?colormap=viridis&indexes=1") 33 | assert r.status_code == 200 34 | 35 | 36 | def test_list_palettes(flask_client): 37 | r = flask_client.get("/api/palettes") 38 | assert r.status_code == 200 39 | 40 | 41 | def test_cog_validate_endpoint(flask_client, remote_file_url): 42 | r = flask_client.get(f"/api/validate?filename={remote_file_url}") 43 | assert r.status_code == 200 44 | 45 | 46 | def test_run_app(): 47 | app = run_app("bahamas", browser=False, run=False) 48 | with app.test_client() as f_client: 49 | r = f_client.get("/api/palettes") 50 | assert r.status_code == 200 51 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | import rasterio 6 | from rasterio.errors import RasterioIOError 7 | import requests 8 | from server_thread import ServerManager 9 | 10 | from localtileserver.client import TileClient, get_or_create_tile_client 11 | from localtileserver.helpers import parse_shapely, polygon_to_geojson 12 | from localtileserver.tiler import get_cache_dir, get_clean_filename 13 | from localtileserver.tiler.utilities import ImageBytes 14 | 15 | from .utilities import get_content 16 | 17 | skip_shapely = False 18 | try: 19 | from shapely.geometry import Polygon 20 | except ImportError: 21 | skip_shapely = True 22 | 23 | TOLERANCE = 2e-2 24 | 25 | 26 | def test_create_tile_client(bahamas_file): 27 | assert ServerManager.server_count() == 0 28 | tile_client = TileClient(bahamas_file, debug=True) 29 | assert str(tile_client.filename) == str(get_clean_filename(bahamas_file)) 30 | assert tile_client.server_port 31 | assert tile_client.server_base_url 32 | assert "crs" in tile_client.metadata 33 | assert tile_client.bounds() 34 | center = tile_client.center() 35 | assert center[0] == pytest.approx(24.5579, abs=TOLERANCE) 36 | assert center[1] == pytest.approx(-77.7668, abs=TOLERANCE) 37 | tile_url = tile_client.get_tile_url().format(z=8, x=72, y=110) 38 | assert get_content(tile_url) # just make sure it doesn't fail 39 | tile_conent = tile_client.tile(z=8, x=72, y=110) 40 | assert tile_conent 41 | tile_url = tile_client.get_tile_url(colormap="plasma").format(z=8, x=72, y=110) 42 | assert get_content(tile_url) # just make sure it doesn't fail 43 | thumb = tile_client.thumbnail() 44 | assert isinstance(thumb, ImageBytes) 45 | assert thumb.mimetype == "image/png" 46 | tile_client.shutdown(force=True) 47 | 48 | 49 | def test_create_tile_client_bad(bahamas_file): 50 | with pytest.raises(OSError): 51 | TileClient("foo.tif", debug=True) 52 | with pytest.raises(ValueError): 53 | TileClient(bahamas_file, port="0", debug=True) 54 | 55 | 56 | def test_client_force_shutdown(bahamas): 57 | tile_url = bahamas.get_tile_url().format(z=8, x=72, y=110) 58 | assert get_content(tile_url) # just make sure it doesn't fail 59 | assert ServerManager.server_count() == 1 60 | bahamas.shutdown(force=True) 61 | assert ServerManager.server_count() == 0 62 | with pytest.raises(requests.ConnectionError): 63 | assert get_content(tile_url) 64 | 65 | 66 | # def test_multiple_tile_clients_one_server(bahamas, blue_marble): 67 | # assert ServerManager.server_count() == 1 68 | # tile_url_a = bahamas.get_tile_url().format(z=8, x=72, y=110) 69 | # tile_url_b = blue_marble.get_tile_url().format(z=8, x=72, y=110) 70 | # assert get_content(tile_url_a) != get_content(tile_url_b) 71 | # thumb_url_a = bahamas.create_url("api/thumbnail.png") 72 | # thumb_url_b = blue_marble.create_url("api/thumbnail.png") 73 | # assert get_content(thumb_url_a) != get_content(thumb_url_b) 74 | 75 | 76 | def test_caching_query_params(bahamas): 77 | thumb_url_a = bahamas.create_url("api/thumbnail.png") 78 | thumb_url_b = bahamas.create_url("api/thumbnail.png?indexes=1") 79 | assert get_content(thumb_url_a) != get_content( 80 | thumb_url_b 81 | ), "Binary content should be different" 82 | thumb_url_c = bahamas.create_url("api/thumbnail.png") 83 | assert get_content(thumb_url_a) == get_content(thumb_url_c), "Binary content should be the same" 84 | 85 | 86 | def test_multiband(bahamas): 87 | url_a = bahamas.get_tile_url( 88 | indexes=[1, 2, 3], 89 | ).format(z=8, x=72, y=110) 90 | assert get_content(url_a) 91 | url_b = bahamas.get_tile_url( 92 | indexes=[3, 2, 1], 93 | ).format(z=8, x=72, y=110) 94 | assert get_content(url_b) 95 | url_c = bahamas.get_tile_url().format(z=8, x=72, y=110) 96 | assert get_content(url_c) 97 | # Check that other options are well handled 98 | url = bahamas.get_tile_url( 99 | indexes=[1, 2, 3], 100 | vmin=0, 101 | vmax=300, 102 | nodata=0, 103 | ).format(z=8, x=72, y=110) 104 | assert get_content(url) # just make sure it doesn't fail 105 | # Check that band names are handled 106 | url = bahamas.get_tile_url( 107 | indexes=bahamas.band_names, 108 | ).format(z=8, x=72, y=110) 109 | assert get_content(url) # just make sure it doesn't fail 110 | 111 | 112 | def test_multiband_vmin_vmax(bahamas): 113 | # Check that other options are well handled 114 | url = bahamas.get_tile_url( 115 | indexes=[3, 2, 1], 116 | vmax=[100, 200, 250], 117 | ).format(z=8, x=72, y=110) 118 | assert get_content(url) # just make sure it doesn't fail 119 | url = bahamas.get_tile_url( 120 | indexes=[3, 2, 1], 121 | vmin=[0, 10, 50], 122 | vmax=[100, 200, 250], 123 | ).format(z=8, x=72, y=110) 124 | assert get_content(url) # just make sure it doesn't fail 125 | with pytest.raises(ValueError): 126 | bahamas.get_tile_url( 127 | vmax=[100, 200, 250], 128 | ).format(z=8, x=72, y=110) 129 | 130 | 131 | def test_vmin_vmax(bahamas): 132 | url = bahamas.get_tile_url( 133 | indexes=[1, 2, 3], 134 | vmin=[100, 200, 250], 135 | ).format(z=8, x=72, y=110) 136 | assert get_content(url) # just make sure it doesn't fail 137 | url = bahamas.get_tile_url( 138 | indexes=[1, 2, 3], 139 | vmax=[20, 50, 70], 140 | ).format(z=8, x=72, y=110) 141 | assert get_content(url) # just make sure it doesn't fail 142 | url = bahamas.get_tile_url( 143 | indexes=[1, 2, 3], 144 | vmin=[20, 50, 70], 145 | vmax=[100, 200, 250], 146 | ).format(z=8, x=72, y=110) 147 | assert get_content(url) # just make sure it doesn't fail 148 | 149 | 150 | def test_launch_non_default_server(bahamas_file): 151 | default = TileClient(bahamas_file) 152 | diff = TileClient(bahamas_file, port=0) 153 | assert default.server != diff.server 154 | assert default.server_port != diff.server_port 155 | 156 | 157 | def test_get_or_create_tile_client(bahamas_file): 158 | tile_client, _ = get_or_create_tile_client(bahamas_file) 159 | same, created = get_or_create_tile_client(tile_client) 160 | assert not created 161 | assert tile_client == same 162 | diff, created = get_or_create_tile_client(bahamas_file) 163 | assert created 164 | assert tile_client != diff 165 | with pytest.raises(RasterioIOError): 166 | _, _ = get_or_create_tile_client(__file__) 167 | 168 | 169 | def test_point(bahamas): 170 | assert bahamas.point(-77.76, 24.56, coord_crs="EPSG:4326") 171 | 172 | 173 | @pytest.mark.parametrize("encoding", ["PNG", "JPEG", "JPG"]) 174 | def test_thumbnail_encodings(bahamas, encoding): 175 | thumbnail = bahamas.thumbnail(encoding=encoding) 176 | assert thumbnail # TODO: check content 177 | assert isinstance(thumbnail, ImageBytes) 178 | output_path = get_cache_dir() / f"thumbnail.{encoding}" 179 | if output_path.exists(): 180 | os.remove(output_path) 181 | thumbnail = bahamas.thumbnail(encoding=encoding, output_path=output_path) 182 | assert os.path.exists(output_path) 183 | 184 | 185 | def test_thumbnail_bad_encoding(bahamas): 186 | with pytest.raises(ValueError): 187 | bahamas.thumbnail(encoding="foo") 188 | 189 | 190 | def test_default_zoom(bahamas): 191 | assert bahamas.default_zoom == 7 192 | 193 | 194 | @pytest.mark.skipif(skip_shapely, reason="shapely not installed") 195 | def test_bounds_polygon(bahamas): 196 | poly = bahamas.bounds(return_polygon=True) 197 | assert isinstance(poly, Polygon) 198 | e = poly.bounds 199 | assert e[0] == pytest.approx(-78.9586, abs=TOLERANCE) 200 | assert e[1] == pytest.approx(23.5650, abs=TOLERANCE) 201 | assert e[2] == pytest.approx(-76.5749, abs=TOLERANCE) 202 | assert e[3] == pytest.approx(25.5509, abs=TOLERANCE) 203 | wkt = bahamas.bounds(return_wkt=True) 204 | assert isinstance(wkt, str) 205 | 206 | 207 | @pytest.mark.skipif(skip_shapely, reason="shapely not installed") 208 | def test_bounds_geojson(bahamas): 209 | poly = bahamas.bounds(return_polygon=True) 210 | assert isinstance(poly, Polygon) 211 | geojson = polygon_to_geojson(poly) 212 | assert isinstance(geojson, str) 213 | obj = json.loads(geojson) 214 | assert isinstance(obj[0], dict) 215 | 216 | 217 | @pytest.mark.skipif(skip_shapely, reason="shapely not installed") 218 | def test_center_shapely(bahamas): 219 | from shapely.geometry import Point 220 | 221 | pt = bahamas.center(return_point=True) 222 | assert isinstance(pt, Point) 223 | wkt = bahamas.center(return_wkt=True) 224 | assert parse_shapely(wkt) 225 | 226 | 227 | def test_rasterio_property(bahamas): 228 | src = bahamas.dataset 229 | assert isinstance(src, rasterio.io.DatasetReaderBase) 230 | assert src == bahamas.dataset 231 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localtileserver import examples 4 | 5 | skip_shapely = False 6 | try: 7 | import shapely # noqa 8 | except ImportError: 9 | skip_shapely = True 10 | 11 | 12 | def test_get_blue_marble(): 13 | client = examples.get_blue_marble() 14 | assert client.metadata 15 | 16 | 17 | def test_get_virtual_earth(): 18 | client = examples.get_virtual_earth() 19 | assert client.metadata 20 | 21 | 22 | def test_get_arcgis(): 23 | client = examples.get_arcgis() 24 | assert client.metadata 25 | 26 | 27 | def test_get_elevation(): 28 | client = examples.get_elevation() 29 | assert client.metadata 30 | 31 | 32 | def test_get_bahamas(): 33 | client = examples.get_bahamas() 34 | assert client.metadata 35 | 36 | 37 | def test_get_landsat(): 38 | client = examples.get_landsat() 39 | assert client.metadata 40 | 41 | 42 | @pytest.mark.skip 43 | def test_get_san_francisco(): 44 | client = examples.get_san_francisco() 45 | assert client.metadata 46 | 47 | 48 | def test_get_oam2(): 49 | client = examples.get_oam2() 50 | assert client.metadata 51 | 52 | 53 | @pytest.mark.skip 54 | def test_get_elevation_us(): 55 | client = examples.get_elevation_us() 56 | assert client.metadata 57 | 58 | 59 | @pytest.mark.xfail(reason="Flaky HTTP request failures") 60 | def test_get_co_elevation(): 61 | client = examples.get_co_elevation() 62 | assert client.metadata 63 | 64 | 65 | def test_get_co_elevation_roi(): 66 | client = examples.get_co_elevation(local_roi=True) 67 | assert client.metadata 68 | 69 | 70 | @pytest.mark.skipif(skip_shapely, reason="shapely not installed") 71 | def test_load_presidio(): 72 | presidio = examples.load_presidio() 73 | assert presidio 74 | -------------------------------------------------------------------------------- /tests/test_folium.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localtileserver import LocalTileServerLayerMixin, get_folium_tile_layer 4 | 5 | skip_folium = False 6 | try: 7 | from folium import TileLayer 8 | except ImportError: 9 | skip_folium = True 10 | 11 | 12 | @pytest.mark.skipif(skip_folium, reason="folium not installed") 13 | def test_folium_tile_layer(bahamas): 14 | layer = get_folium_tile_layer(bahamas) 15 | assert isinstance(layer, TileLayer) 16 | with pytest.raises(ValueError): 17 | layer = get_folium_tile_layer(bahamas, indexes=1, colormap="foobar") 18 | layer = get_folium_tile_layer( 19 | bahamas, indexes=1, colormap="viridis", vmin=0, vmax=255, nodata=0 20 | ) 21 | assert isinstance(layer, TileLayer) 22 | assert isinstance(layer, LocalTileServerLayerMixin) 23 | 24 | 25 | @pytest.mark.skipif(skip_folium, reason="folium not installed") 26 | def test_folium_tile_layer_from_path(bahamas_file): 27 | layer = get_folium_tile_layer(bahamas_file) 28 | assert isinstance(layer, TileLayer) 29 | assert isinstance(layer, LocalTileServerLayerMixin) 30 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | import rasterio 5 | 6 | from localtileserver import helpers 7 | from localtileserver.tiler import get_data_path 8 | 9 | 10 | def test_hillshade(): 11 | path = str(get_data_path("co_elevation_roi.tif")) 12 | ds = rasterio.open(path) 13 | dem = ds.read(1) 14 | hs_arr = helpers.hillshade(dem) 15 | assert isinstance(hs_arr, np.ndarray) 16 | 17 | 18 | def test_save_new_raster(): 19 | path = get_data_path("co_elevation_roi.tif") 20 | src = rasterio.open(path) 21 | path = helpers.save_new_raster(src, np.random.rand(10, 10)) 22 | assert os.path.exists(path) 23 | -------------------------------------------------------------------------------- /tests/test_leaflet.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localtileserver import LocalTileServerLayerMixin, get_leaflet_tile_layer 4 | 5 | skip_leaflet = False 6 | try: 7 | from ipyleaflet import TileLayer 8 | except ImportError: 9 | skip_leaflet = True 10 | 11 | skip_shapely = False 12 | try: 13 | import shapely # noqa 14 | except ImportError: 15 | skip_shapely = True 16 | 17 | 18 | @pytest.mark.skipif(skip_leaflet, reason="ipyleaflet not installed") 19 | def test_leaflet_tile_layer(bahamas): 20 | layer = get_leaflet_tile_layer(bahamas) 21 | assert isinstance(layer, TileLayer) 22 | with pytest.raises(ValueError): 23 | layer = get_leaflet_tile_layer(bahamas, indexes=1, colormap="foobar") 24 | layer = get_leaflet_tile_layer( 25 | bahamas, indexes=1, colormap="viridis", vmin=0, vmax=255, nodata=0 26 | ) 27 | assert isinstance(layer, TileLayer) 28 | assert isinstance(layer, LocalTileServerLayerMixin) 29 | 30 | 31 | @pytest.mark.skipif(skip_leaflet, reason="ipyleaflet not installed") 32 | def test_leaflet_tile_layer_from_path(bahamas_file): 33 | layer = get_leaflet_tile_layer(bahamas_file) 34 | assert isinstance(layer, TileLayer) 35 | assert isinstance(layer, LocalTileServerLayerMixin) 36 | -------------------------------------------------------------------------------- /tests/test_rendering.py: -------------------------------------------------------------------------------- 1 | from matplotlib.colors import ListedColormap 2 | import pytest 3 | 4 | from .utilities import get_content 5 | 6 | 7 | def test_thumbnail(bahamas, compare): 8 | thumb_a = bahamas.thumbnail() 9 | thumb_b = bahamas.thumbnail( 10 | indexes=[1, 2, 3], 11 | ) 12 | assert thumb_a == thumb_b 13 | compare(thumb_a) 14 | 15 | 16 | @pytest.mark.parametrize("colormap,indexes", [("viridis", None), ("viridis", 1), ("inferno", 1)]) 17 | def test_thumbnail_colormap(bahamas, compare, colormap, indexes): 18 | thumb = bahamas.thumbnail(colormap=colormap, indexes=indexes) 19 | compare(thumb) 20 | 21 | 22 | def test_tile( 23 | bahamas, 24 | compare, 25 | ): 26 | url_a = bahamas.get_tile_url().format(z=8, x=72, y=110) 27 | tile_a = get_content(url_a) 28 | url_b = bahamas.get_tile_url( 29 | indexes=[1, 2, 3], 30 | ).format(z=8, x=72, y=110) 31 | tile_b = get_content(url_b) 32 | direct_content = bahamas.tile(z=8, x=72, y=110, indexes=[1, 2, 3]) 33 | assert tile_a == tile_b == direct_content 34 | compare(direct_content) 35 | 36 | 37 | @pytest.mark.parametrize("indexes", [[3, 2, 1], [2, 1, 3], 1, 2, 3]) 38 | def test_tile_indexes(bahamas, compare, indexes): 39 | url_a = bahamas.get_tile_url(indexes=indexes).format(z=8, x=72, y=110) 40 | tile_a = get_content(url_a) 41 | direct_content = bahamas.tile(z=8, x=72, y=110, indexes=indexes) 42 | assert tile_a == direct_content 43 | compare(direct_content) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "colormap,indexes", [(None, None), ("viridis", None), ("viridis", 1), ("inferno", 1)] 48 | ) 49 | def test_tile_colormap(bahamas, compare, colormap, indexes): 50 | # Get a tile over the REST API 51 | tile_url = bahamas.get_tile_url(colormap=colormap, indexes=indexes).format(z=8, x=72, y=110) 52 | rest_content = get_content(tile_url) 53 | # Get tile directly 54 | direct_content = bahamas.tile(z=8, x=72, y=110, colormap=colormap, indexes=indexes) 55 | # Make sure they are the same 56 | assert rest_content == direct_content 57 | compare(direct_content) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "colormap,indexes", 62 | [ 63 | (ListedColormap(["red", "blue"]), None), 64 | (ListedColormap(["blue", "green"]), 2), 65 | ], 66 | ) 67 | def test_custom_colormap(bahamas, compare, colormap, indexes): 68 | # Get a tile over the REST API 69 | tile_url = bahamas.get_tile_url(colormap=colormap, indexes=indexes).format(z=8, x=72, y=110) 70 | rest_content = get_content(tile_url) 71 | # Get tile directly 72 | direct_content = bahamas.tile(z=8, x=72, y=110, colormap=colormap, indexes=indexes) 73 | # Make sure they are the same 74 | assert rest_content == direct_content 75 | compare(direct_content) 76 | 77 | 78 | @pytest.mark.parametrize("vmin", [100, [100, 200, 250]]) 79 | def test_tile_vmin(bahamas, compare, vmin): 80 | url = bahamas.get_tile_url( 81 | indexes=[1, 2, 3], 82 | vmin=vmin, 83 | ).format(z=8, x=72, y=110) 84 | tile_a = get_content(url) 85 | direct_content = bahamas.tile(z=8, x=72, y=110, indexes=[1, 2, 3], vmin=vmin) 86 | assert tile_a == direct_content 87 | compare(direct_content) 88 | 89 | 90 | @pytest.mark.parametrize("vmax", [100, [100, 200, 250]]) 91 | def test_tile_vmax(bahamas, compare, vmax): 92 | url = bahamas.get_tile_url( 93 | indexes=[1, 2, 3], 94 | vmax=vmax, 95 | ).format(z=8, x=72, y=110) 96 | tile_a = get_content(url) 97 | direct_content = bahamas.tile(z=8, x=72, y=110, indexes=[1, 2, 3], vmax=vmax) 98 | assert tile_a == direct_content 99 | compare(direct_content) 100 | 101 | 102 | @pytest.mark.parametrize("nodata", [None, 0, 255]) 103 | def test_thumbnail_nodata(bahamas, compare, nodata): 104 | thumb = bahamas.thumbnail(nodata=nodata) 105 | compare(thumb) 106 | 107 | 108 | @pytest.mark.parametrize("nodata", [None, 0, 255]) 109 | def test_tile_nodata(bahamas, compare, nodata): 110 | url = bahamas.get_tile_url( 111 | nodata=nodata, 112 | ).format(z=8, x=72, y=110) 113 | tile_a = get_content(url) 114 | direct_content = bahamas.tile(z=8, x=72, y=110, nodata=nodata) 115 | assert tile_a == direct_content 116 | compare(direct_content) 117 | 118 | 119 | def test_tile_vmin_vmax(bahamas, compare): 120 | url = bahamas.get_tile_url(vmin=50, vmax=100).format(z=8, x=72, y=110) 121 | tile_a = get_content(url) 122 | direct_content = bahamas.tile(z=8, x=72, y=110, vmin=50, vmax=100) 123 | assert tile_a == direct_content 124 | compare(direct_content) 125 | 126 | 127 | @pytest.mark.parametrize("nodata", [None, 0, 255]) 128 | def test_landsat7_nodata(landsat7, compare, nodata): 129 | thumbnail = landsat7.thumbnail(nodata=nodata) 130 | compare(thumbnail) 131 | -------------------------------------------------------------------------------- /tests/test_utilities.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localtileserver import Report, TileClient 4 | from localtileserver.tiler.palettes import get_palettes, is_rio_cmap 5 | from localtileserver.validate import validate_cog 6 | 7 | has_mpl = False 8 | try: 9 | import matplotlib # noqa 10 | 11 | has_mpl = True 12 | except ImportError: 13 | pass 14 | 15 | 16 | def test_is_valid_palette_name(): 17 | assert is_rio_cmap("viridis") 18 | assert not is_rio_cmap("foobar") 19 | 20 | 21 | @pytest.mark.skipif(not has_mpl, reason="matplotlib not installed.") 22 | def test_mpl_colormaps(): 23 | assert is_rio_cmap("viridis") 24 | assert is_rio_cmap("jet") 25 | 26 | 27 | def test_report(): 28 | assert Report() 29 | 30 | 31 | def test_get_palettes(): 32 | assert isinstance(get_palettes(), dict) 33 | 34 | 35 | def test_cog_validate(remote_file_url): 36 | assert validate_cog(remote_file_url) 37 | client = TileClient(remote_file_url) 38 | assert validate_cog(client) 39 | 40 | 41 | def test_cog_validate_bahamas(bahamas): 42 | assert validate_cog(bahamas) 43 | -------------------------------------------------------------------------------- /tests/test_vsi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from localtileserver.client import TileClient 4 | 5 | 6 | def test_tileclient_with_vsi(remote_file_url): 7 | client = TileClient(remote_file_url) 8 | assert "bounds" in client.metadata 9 | 10 | 11 | @pytest.mark.skip 12 | def test_tileclient_with_vsi_s3(remote_file_s3): 13 | client = TileClient(remote_file_s3) 14 | assert "bounds" in client.metadata 15 | -------------------------------------------------------------------------------- /tests/utilities.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_content(url, timeout=5, **kwargs): 5 | r = requests.get(url, timeout=timeout, **kwargs) 6 | r.raise_for_status() 7 | return r.content 8 | --------------------------------------------------------------------------------