├── .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 | 
2 |
3 | # 🌐 Local Tile Server for Geospatial Rasters
4 |
5 | [](https://codecov.io/gh/banesullivan/localtileserver)
6 | [](https://pypi.org/project/localtileserver/)
7 | [](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 [](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 | 
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 |
17 |
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 |
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 |
--------------------------------------------------------------------------------