diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..0254016b --- /dev/null +++ b/.flake8 @@ -0,0 +1,23 @@ +[flake8] +exclude = .git,__pycache__,build,dist,doc/build +ignore = + # whitespace before ':' + E203, + # line break before binary operator + W503, + # line length too long + E501, + # do not assign a lambda expression, use a def + E731, + # too many leading '#' for block comment + E266, + # ambiguous variable name + E741, + # module level import not at top of file + E402, + # Quotes (temporary) + Q0, + # bare excepts (temporary) + B001, E722 + # we already check black + BLK100 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fadeef48..219e8b37 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,8 +17,6 @@ jobs: - name: Install Style dependencies run: | python -m pip install --upgrade pip - pip install -r requirements_style.txt + pip install pre-commit - name: Run linting run: make lint - - name: Run codespell - run: make codespell diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 7d725ad2..689d3543 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -2,7 +2,8 @@ name: Docker Package on: workflow_dispatch: push: - tags: "*" + tags: + - "*" branches: - main pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d76bfaa3..58e3f3fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Package Release on: push: - tags: "*" + tags: + - "*" jobs: publish: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fde91ea..f3b73ad7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10"] + python-version: [3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..cc82b4da --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +repos: +- repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: requirements-txt-fixer + - id: trailing-whitespace + - id: check-docstring-first + - id: end-of-file-fixer + - id: mixed-line-ending + +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.27.3 + hooks: + - id: check-github-workflows + + +- repo: https://github.com/PyCQA/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + additional_dependencies: [ + "flake8-black==0.3.6", + "flake8-isort==6.0.0", + "flake8-quotes==3.3.2", + ] + + +# - repo: https://github.com/codespell-project/codespell +# rev: v2.2.6 +# hooks: +# - id: codespell +# args: [ +# "doc examples examples_trame pyvista tests", +# "*.py *.rst *.md", +# ] +# additional_dependencies: [ +# "tomli" +# ] diff --git a/LICENSE b/LICENSE index 05272421..e72fb2c1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Bane Sullivan +Copyright (c) 2021-2024 Bane Sullivan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 0aa527ce..b770e272 100644 --- a/Makefile +++ b/Makefile @@ -21,9 +21,8 @@ doctest: lint: @echo "Linting with flake8" - flake8 --ignore=E501 localtileserver tests + pre-commit run --all-files format: @echo "Formatting" - black . - isort . + pre-commit run --all-files diff --git a/README.md b/README.md index 578f688f..7b0ec3b2 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,7 @@ standalone web app or in your own web deployments needing dynamic tile serving. - Launch a tile server for large geospatial images - View local or remote* raster files with `ipyleaflet` or `folium` in Jupyter -- View rasters with CesiumJS with the built-in Flask web application -- Extract regions of interest (ROIs) interactively -- Use the example datasets to generate Digital Elevation Models +- View rasters with CesiumJS with the built-in web application **remote raster files should be pre-tiled Cloud Optimized GeoTiffs* @@ -48,7 +46,7 @@ client = TileClient('path/to/geo.tif') t = get_leaflet_tile_layer(client) m = Map(center=client.center(), zoom=client.default_zoom) -m.add_layer(t) +m.add(t) m ``` @@ -62,13 +60,10 @@ thread which will serve raster imagery to a viewer (usually `ipyleaflet` or This tile server can efficiently deliver varying resolutions of your raster imagery to your viewer; it helps to have pre-tiled, -[Cloud Optimized GeoTIFFs (COGs)](https://www.cogeo.org/), but no wories if -not as the backing library, [`large_image`](https://github.com/girder/large_image), -will tile and cache for you when opening the raster. +[Cloud Optimized GeoTIFFs (COGs)](https://www.cogeo.org/). There is an included, standalone web viewer leveraging -[CesiumJS](https://cesium.com/platform/cesiumjs/) and [GeoJS](https://opengeoscience.github.io/geojs/). -You can use the web viewer to select and extract regions of interest from rasters. +[CesiumJS](https://cesium.com/platform/cesiumjs/). ## ⬇️ Installation diff --git a/doc/source/_static/fontawesome/webfonts/fa-brands-400.svg b/doc/source/_static/fontawesome/webfonts/fa-brands-400.svg index caa8cc43..ef37ead0 100644 --- a/doc/source/_static/fontawesome/webfonts/fa-brands-400.svg +++ b/doc/source/_static/fontawesome/webfonts/fa-brands-400.svg @@ -8,7 +8,7 @@ Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `_) with :func:`get_folium_tile_layer`. Here is an example with almost the exact same code as the ``ipyleaflet`` example, just note that :class:`folium.Map` is imported from -``folium`` and we use :func:`add_child` instead of :func:`add_layer`: +``folium`` and we use :func:`add_child` instead of :func:`add`: .. jupyter-execute:: @@ -117,7 +117,6 @@ code as the ``ipyleaflet`` example, just note that :class:`folium.Map` is import -------------- - :func:`get_leaflet_tile_layer` accepts either an existing :class:`TileClient` or a path from which to create a :class:`TileClient` under the hood. -- The color palette choices come from `palettable `_. - If matplotlib is installed, any matplotlib colormap name cane be used a palette choice diff --git a/doc/source/user-guide/ipyleaflet_deep_zoom.rst b/doc/source/user-guide/ipyleaflet_deep_zoom.rst index 945d65ad..d0821108 100644 --- a/doc/source/user-guide/ipyleaflet_deep_zoom.rst +++ b/doc/source/user-guide/ipyleaflet_deep_zoom.rst @@ -30,5 +30,5 @@ For more information, please see https://github.com/jupyter-widgets/ipyleaflet/i m = Map(center=client.center(), zoom=22, max_zoom=max_zoom ) - m.add_layer(layer) + m.add(layer) m diff --git a/doc/source/user-guide/rasterio.rst b/doc/source/user-guide/rasterio.rst index f5222522..5c2a7c84 100644 --- a/doc/source/user-guide/rasterio.rst +++ b/doc/source/user-guide/rasterio.rst @@ -19,7 +19,7 @@ This will only work when opening a raster in read-mode. t = get_leaflet_tile_layer(client) m = Map(center=client.center(), zoom=client.default_zoom) - m.add_layer(t) + m.add(t) m @@ -34,4 +34,4 @@ and keeps a reference to a ``rasterio.DatasetReader`` for all clients. # Load example tile layer from publicly available DEM source client = examples.get_elevation() - client.rasterio + client.dataset diff --git a/doc/source/user-guide/remote-cog.rst b/doc/source/user-guide/remote-cog.rst index 64c94832..aec87bad 100644 --- a/doc/source/user-guide/remote-cog.rst +++ b/doc/source/user-guide/remote-cog.rst @@ -43,7 +43,7 @@ Or we can do the same ipyleaflet: l = get_leaflet_tile_layer(client) m = ipyleaflet.Map(center=client.center(), zoom=client.default_zoom) - m.add_layer(l) + m.add(l) m diff --git a/doc/source/user-guide/rgb.rst b/doc/source/user-guide/rgb.rst index a01f929b..ea7e8cbb 100644 --- a/doc/source/user-guide/rgb.rst +++ b/doc/source/user-guide/rgb.rst @@ -3,7 +3,7 @@ The ``ipyleaflet`` and ``folium`` tile layer utilities support setting which bands to view as the RGB channels. To set the RGB bands, pass a length three list -of the band indices to the ``band`` argument. +of the band indices to the ``indexes`` argument. Here is an example where I create two tile layers from the same raster but viewing a different set of bands: @@ -19,19 +19,19 @@ viewing a different set of bands: .. jupyter-execute:: - client.thumbnail(band=[7, 5, 4]) + client.thumbnail(indexes=[7, 5, 4]) .. jupyter-execute:: - client.thumbnail(band=[5, 3, 2]) + client.thumbnail(indexes=[5, 3, 2]) .. jupyter-execute:: # Create 2 tile layers from same raster viewing different bands - l = get_leaflet_tile_layer(client, band=[7, 5, 4]) - r = get_leaflet_tile_layer(client, band=[5, 3, 2]) + l = get_leaflet_tile_layer(client, indexes=[7, 5, 4]) + r = get_leaflet_tile_layer(client, indexes=[5, 3, 2]) # Make the ipyleaflet map m = Map(center=client.center(), zoom=client.default_zoom) @@ -40,35 +40,3 @@ viewing a different set of bands: m.add_control(ScaleControl(position='bottomleft')) m.add_control(FullScreenControl()) m - - -Additionally, ``localtileserver`` supports a full styling specification -from ``large-image`` for more complex composite images. - -See https://girder.github.io/large_image/tilesource_options.html#style - -.. jupyter-execute:: - - from localtileserver import get_leaflet_tile_layer, examples - from ipyleaflet import Map - - client = examples.get_landsat() - - style = { - 'bands': [ - {'band': 5, 'palette': '#f00'}, - {'band': 3, 'palette': '#0f0'}, - {'band': 2, 'palette': '#00f'}, - ] - } - - client.thumbnail(style=style) - - -.. jupyter-execute:: - - l = get_leaflet_tile_layer(client, style=style) - - m = Map(center=client.center(), zoom=client.default_zoom) - m.add_layer(l) - m diff --git a/doc/source/user-guide/roi.rst b/doc/source/user-guide/roi.rst deleted file mode 100644 index 6a9e4d7c..00000000 --- a/doc/source/user-guide/roi.rst +++ /dev/null @@ -1,111 +0,0 @@ -🎯 ROI Extraction ------------------ - -The :class:`localtileserver.TileClient` class has a few methods for extracting -regions of interest (ROIs): - -- :func:`localtileserver.TileClient.extract_roi` -- :func:`localtileserver.TileClient.extract_roi_shape` -- :func:`localtileserver.TileClient.extract_roi_pixel` - -These methods can be used to extract rectangular regions from large images -using world coordinates, Shapely geometry, or pixel bounds. - -.. note:: - - The following example needs ``shapely`` to be installed. - - -.. jupyter-execute:: - - from localtileserver import examples, get_leaflet_tile_layer - from ipyleaflet import Map, WKTLayer - - client = examples.get_san_francisco() - presidio_roi = examples.load_presidio() - - presidio_layer = WKTLayer( - wkt_string=presidio_roi.wkt, - style={'fillOpacity': 0, 'weight': 1}, - hover_style={ - 'color': 'white', 'fillOpacity': 0 - }, - ) - - m = Map(center=client.center(), zoom=client.default_zoom) - m.add_layer(get_leaflet_tile_layer(client)) - m.add_layer(presidio_layer) - m - -Perform ROI extraction with Shapely object - -.. jupyter-execute:: - - presidio = client.extract_roi_shape(presidio_roi, encoding='PNG', return_bytes=True) - presidio - - -------- - - -.. code:: python - - from localtileserver import TileClient, get_leaflet_tile_layer, examples - from ipyleaflet import Map, WKTLayer - - client = examples.get_san_francisco() - presidio_roi = examples.load_presidio() - - # Perform ROI extraction with Shapely object - presidio = client.extract_roi_shape(presidio_roi) - - presidio_layer = WKTLayer( - wkt_string=presidio.wkt, - style={'fillOpacity': 0, 'weight': 1}, - hover_style={ - 'color': 'white', 'fillOpacity': 0 - }, - ) - - m = Map(center=presidio.center(), zoom=presidio.default_zoom) - m.add_layer(get_leaflet_tile_layer(presidio)) - m.add_layer(presidio_layer) - m - - -.. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/presidio.png - - -User Interface with ``ipyleaflet`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -I have included the :func:`get_leaflet_roi_controls` utility to create some leaflet -UI controls for extracting regions of interest from a tile client. You can -use it as follows and then draw a polygon and click the "Extract ROI" button. - -The outputs are save in your working directory by default (next to the Jupyter notebook). - -.. code:: python - - from localtileserver import get_leaflet_tile_layer, get_leaflet_roi_controls - from localtileserver import examples - from ipyleaflet import Map - - # First, create a TileClient from example raster file - client = examples.get_san_francisco() - - # Create ipyleaflet tile layer from that server - t = get_leaflet_tile_layer(client) - - # Create ipyleaflet controls to extract an ROI - draw_control, roi_control = get_leaflet_roi_controls(client) - - # Create ipyleaflet map, add layers, add controls, and display - m = Map(center=(37.7249511580583, -122.27230466902257), zoom=9) - m.add_layer(t) - m.add_control(draw_control) - m.add_control(roi_control) - m - - -.. image:: https://raw.githubusercontent.com/banesullivan/localtileserver/main/imgs/ipyleaflet-draw-roi.png diff --git a/doc/source/user-guide/validate_cog.rst b/doc/source/user-guide/validate_cog.rst index 1c33e64d..950888a7 100644 --- a/doc/source/user-guide/validate_cog.rst +++ b/doc/source/user-guide/validate_cog.rst @@ -4,12 +4,9 @@ ``localtileserver`` includes a helper method to validate whether or not a source image meets the requirements of a Cloud Optimized GeoTiff. -:func:`localtileserver.validate.validate_cog` users the -``validate_cloud_optimized_geotiff`` script from ``osgeo_utils`` to check if -an image is a GeoTiff with the proper tiling and overviews to be considered -"Cloud Optimized". If the validation fails, this method will raise an -``large_image.exceptions.TileSourceInefficientError`` -error. +:func:`localtileserver.validate.validate_cog` uses rio-cogeo to validate +whether or not a source image meets the requirements of a Cloud Optimized +GeoTIFF. You can use the script by: @@ -20,7 +17,7 @@ You can use the script by: # Path to raster (URL or local path) url = 'https://opendata.digitalglobe.com/events/california-fire-2020/pre-event/2018-02-16/pine-gulch-fire20/1030010076004E00.tif' - # If invalid, raises TileSourceInefficientError + # If invalid, returns False validate_cog(url) @@ -32,84 +29,5 @@ This can also be used with an existing :class:`localtileserver.TileClient`: client = examples.get_san_francisco() - # If invalid, raises TileSourceInefficientError + # If invalid, returns False validate_cog(client) - - -↔️ Converting to a COG -~~~~~~~~~~~~~~~~~~~~~~ - -Converting an image to a Cloud Optimized GeoTiff, while easy, isn't always -straightforward. I often find myself needing to recall *exactly* how to do it -or need to point people to a resource on how to perform the conversion *so that -the resulting image is not only a COG but a performant COG.* - -This *brief* section is a place for me to note how to convert imagery to a -COG. - -The easiest method is to use ``large_image_converter``: https://pypi.org/project/large-image-converter/ - -.. code-block:: python - - import large_image_converter - - large_image_converter.convert(str(input_path), str(output_path)) - -Under the hood, this is using GDAL's translate utility to perform the -conversion with a few cleverly chosen options set to better (opinionated) -default values: - -.. code-block:: bash - - gdal_translate .tiff \ - -of COG \ - -co BIGTIFF=IF_SAFER \ - -co BLOCKSIZE=256 \ - -co COMPRESS=LZW \ - -co PREDICTOR=YES \ - -co QUALITY=90 - -or in Python: - -.. code-block:: python - - from osgeo import gdal - - options = [ - '-of', - 'COG', - '-co', - 'BIGTIFF=IF_SAFER', - '-co', - 'COMPRESS=LZW', - '-co', - 'PREDICTOR=YES', - '-co', - 'BLOCKSIZE=256', - '-co', - 'QUALITY=90' - ] - - ds = gdal.Open(src_path) - ds = gdal.Translate(output_path, ds, options=options) - - -I want to elaborate a bit on what I meant when I stated the statement above: - - so that the resulting image is not only a COG but a performant COG. - -I'm planning to write a thorough blog post on this topic, but the gist is that -a COG is a performant COG when two criteria are properly met: - -1. **Tiling:** the bytes of the image data are arranged in tiles such that data that are geographically close are adjacent within the file. This is opposed to typical striping patterns. -2. **Overviews:** Embedded in the image are “zoomed out”, lower-resolution versions of the image down to 256x256 pixels (or 512x512), effectively creating a pyramid of resolutions. - -`cogeo.org `_ does a wonderful job -explaining these concepts - for further details, please refer to their in-depth -explanation. - -While many routines to generate a COG exist out there, many of them do not -properly handle both tiling and generating overviews. Often, this is not a big -deal, but when dealing with massive amounts of imagery, the tiling block -sizes, compression scheme, and ensuring overviews are present can make -significant performances increases. diff --git a/environment.yml b/environment.yml index 62e8a1d9..1a967836 100644 --- a/environment.yml +++ b/environment.yml @@ -3,15 +3,15 @@ channels: - conda-forge dependencies: - python>=3.8 - - large-image-source-rasterio - - large-image-source-tiff + - rasterio + - rio-tiler + - rio-cogeo - click - requests - flask >=2.0.0 - Flask-Caching - flask-cors - flask-restx >=0.5.0 - - pylibmc - requests - server-thread - scooby diff --git a/example.ipynb b/example.ipynb index 805f55a8..e7abcd8d 100644 --- a/example.ipynb +++ b/example.ipynb @@ -38,7 +38,7 @@ "\n", "# Create ipyleaflet map, add tile layer, and display\n", "m = Map(center=b_client.center(), zoom=b_client.default_zoom)\n", - "m.add_layer(t)\n", + "m.add(t)\n", "m" ] }, @@ -70,8 +70,8 @@ "landsat_client = examples.get_landsat()\n", "\n", "# Create 2 tile layers from same raster viewing different bands\n", - "l = get_leaflet_tile_layer(landsat_client, band=[7, 5, 4])\n", - "r = get_leaflet_tile_layer(landsat_client, band=[5, 3, 2])\n", + "l = get_leaflet_tile_layer(landsat_client, indexes=[7, 5, 4])\n", + "r = get_leaflet_tile_layer(landsat_client, indexes=[5, 3, 2])\n", "\n", "# Make the ipyleaflet map\n", "m = Map(center=landsat_client.center(), zoom=landsat_client.default_zoom)\n", @@ -85,7 +85,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7039ea47-b232-494a-aa57-0285d6439d3a", + "id": "1468bb2a-aa61-453c-a174-0dfc5a228b77", "metadata": {}, "outputs": [], "source": [] @@ -93,9 +93,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Geospatial (rio)", "language": "python", - "name": "python3" + "name": "pyenv_rio" }, "language_info": { "codemirror_mode": { @@ -107,7 +107,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.4" + "version": "3.11.6" } }, "nbformat": 4, diff --git a/localtileserver/__init__.py b/localtileserver/__init__.py index f21f1091..b3efc075 100644 --- a/localtileserver/__init__.py +++ b/localtileserver/__init__.py @@ -1,6 +1,6 @@ # flake8: noqa: F401 from localtileserver._version import __version__ -from localtileserver.client import RemoteTileClient, TileClient, get_or_create_tile_client +from localtileserver.client import TileClient, get_or_create_tile_client from localtileserver.helpers import hillshade, parse_shapely, polygon_to_geojson, save_new_raster from localtileserver.report import Report from localtileserver.tiler import get_cache_dir, make_vsi, purge_cache @@ -8,6 +8,5 @@ from localtileserver.widgets import ( LocalTileServerLayerMixin, get_folium_tile_layer, - get_leaflet_roi_controls, get_leaflet_tile_layer, ) diff --git a/localtileserver/client.py b/localtileserver/client.py index 4dfe1b1b..0e5f0048 100644 --- a/localtileserver/client.py +++ b/localtileserver/client.py @@ -1,16 +1,11 @@ -# flake8: noqa: W503 from collections.abc import Iterable -from functools import wraps -import json import logging import pathlib -import shutil from typing import List, Optional, Union -from urllib.parse import quote -from large_image_source_rasterio import RasterioFileTileSource import rasterio import requests +from rio_tiler.io import Reader try: import ipyleaflet @@ -24,322 +19,104 @@ from server_thread import ServerManager, launch_server from localtileserver.configure import get_default_client_params -from localtileserver.helpers import parse_shapely from localtileserver.manager import AppManager from localtileserver.tiler import ( format_to_encoding, get_building_docs, - get_clean_filename, get_meta_data, - get_region_pixel, - get_region_world, - get_tile_bounds, - get_tile_source, - make_style, + get_point, + get_preview, + get_reader, + get_source_bounds, + get_tile, palette_valid_or_raise, ) -from localtileserver.utilities import ImageBytes, add_query_parameters, save_file_from_request +from localtileserver.utilities import add_query_parameters BUILDING_DOCS = get_building_docs() DEMO_REMOTE_TILE_SERVER = "https://tileserver.banesullivan.com/" logger = logging.getLogger(__name__) -class BaseTileClientInterface: +class TilerInterface: """Base TileClient methods and configuration. - This class does not perform any RESTful operations but will interface - directly with large-image to produce results. + This class interfaces directly with rasterio and rio-tiler. Parameters ---------- - path : pathlib.Path, str - The path on disk to use as the source raster for the tiles. + source : pathlib.Path, str, Reader, DatasetReaderBase + The source dataset to use for the tile client. """ def __init__( self, - filename: Union[pathlib.Path, str], - default_projection: Optional[str] = "EPSG:3857", + source: Union[pathlib.Path, str, rasterio.io.DatasetReaderBase], ): - self._filename = get_clean_filename(filename) - self._metadata = {} - self._is_geospatial = None - - if default_projection != "EPSG:3857": - self._default_projection = default_projection + if isinstance(source, rasterio.io.DatasetReaderBase): # and hasattr(source, "name"): + self._reader = get_reader(source.name) + elif isinstance(source, Reader): + self._reader = source else: - self._default_projection = "" + self._reader = get_reader(source) @property - def filename(self): - return self._filename + def reader(self): + return self._reader @property - def default_projection(self): - if self._default_projection == "": - self._default_projection = "EPSG:3857" if self.is_geospatial else None - return self._default_projection - - @default_projection.setter - def default_projection(self, value): - self._default_projection = value + def dataset(self): + return self.reader.dataset @property - def rasterio(self): - """Open dataset with rasterio.""" - if hasattr(self, "_rasterio_ds"): - return self._rasterio_ds - self._rasterio_ds = rasterio.open(self.filename, "r") - return self._rasterio_ds + def filename(self): + return self.dataset.name @property - def server_host(self): - raise NotImplementedError # pragma: no cover + def info(self): + return self.reader.info() @property - def server_port(self): - raise NotImplementedError # pragma: no cover + def metadata(self): + return get_meta_data(self.reader) @property - def server_base_url(self): - raise NotImplementedError # pragma: no cover - - def _produce_url(self, base: str): - return add_query_parameters(base, {"filename": self._filename}) - - def create_url(self, path: str, **kwargs): - return self._produce_url(f"{self.server_base_url}/{path.lstrip('/')}") + def min_zoom(self): + return self.info.minzoom - def _get_style_params( - self, - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, - style: dict = None, - cmap: Union[str, List[str]] = None, - ): - if style: - return {"style": quote(json.dumps(style))} - # First handle query parameters to check for errors - params = {} - if band is not None: - params["band"] = band - if palette is not None or cmap is not None: - if palette is None: - palette = cmap - # make sure palette is valid - palette_valid_or_raise(palette) - params["palette"] = palette - if vmin is not None: - if isinstance(vmin, Iterable) and not isinstance(band, Iterable): - raise ValueError("`band` must be explicitly set if `vmin` is an iterable.") - params["min"] = vmin - if vmax is not None: - if isinstance(vmax, Iterable) and not isinstance(band, Iterable): - raise ValueError("`band` must be explicitly set if `vmax` is an iterable.") - params["max"] = vmax - if nodata is not None: - if isinstance(nodata, Iterable) and not isinstance(band, Iterable): - raise ValueError("`band` must be explicitly set if `nodata` is an iterable.") - params["nodata"] = nodata - if scheme is not None: - if (not isinstance(scheme, str) and isinstance(scheme, Iterable)) and not isinstance( - band, Iterable - ): - raise ValueError("`band` must be explicitly set if `scheme` is an iterable.") - params["scheme"] = scheme - if n_colors: - params["n_colors"] = n_colors - return params - - def get_tile_url_params( - self, - projection: Optional[str] = "", - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, - grid: bool = False, - style: dict = None, - cmap: Union[str, List[str]] = None, - ): - """Get slippy maps tile URL (e.g., `/zoom/x/y.png`). - - Parameters - ---------- - projection : str - The Proj projection to use for the tile layer. Default is `EPSG:3857`. - band : int - The band of the source raster to use (default in None to show RGB if - available). Band indexing starts at 1. This can also be a list of - integers to set which 3 bands to use for RGB. - palette : str - The name of the color palette from `palettable` or colormap from - matplotlib to use when plotting a single band. Default is greyscale. - If viewing a single band, a list of hex colors can be passed for a - user-defined color palette. - vmin : float - The minimum value to use when colormapping the palette when plotting - a single band. - vmax : float - The maximized value to use when colormapping the palette when plotting - a single band. - nodata : float - The value from the band to use to interpret as not valid data. - scheme : str - This is either ``linear`` (the default) or ``discrete``. If a - palette is specified, ``linear`` uses a piecewise linear - interpolation, and ``discrete`` uses exact colors from the palette - with the range of the data mapped into the specified number of - colors (e.g., a palette with two colors will split exactly halfway - between the min and max values). - n_colors : int - The number (positive integer) of colors to discretize the matplotlib - color palettes when used. - grid : bool - Show the outline of each tile. This is useful when debugging your - tile viewer. - style : dict, optional - large-image JSON style. See - https://girder.github.io/large_image/tilesource_options.html#style - If given, this will override all other styling parameters. - cmap : str - Alias for palette if not specified. - - """ - params = self._get_style_params( - band=band, - palette=palette, - vmin=vmin, - vmax=vmax, - nodata=nodata, - scheme=scheme, - n_colors=n_colors, - style=style, - cmap=cmap, - ) - if not projection: - projection = self.default_projection - params["projection"] = projection - if grid: - params["grid"] = True - return params - - @wraps(get_tile_url_params) - def get_tile_url(self, *args, client: bool = False, **kwargs): - params = self.get_tile_url_params(*args, **kwargs) - return add_query_parameters( - self.create_url("api/tiles/{z}/{x}/{y}.png", client=client), params - ) - - def get_tile(self, z: int, x: int, y: int, *args, **kwargs): - """Get single tile binary.""" - raise NotImplementedError # pragma: no cover - - def extract_roi( - self, - left: float, - right: float, - bottom: float, - top: float, - units: str = "EPSG:4326", - encoding: str = "TILED", - output_path: pathlib.Path = None, - return_bytes: bool = False, - return_path: bool = False, - ): - """Extract ROI in world coordinates.""" - raise NotImplementedError # pragma: no cover - - def extract_roi_shape( - self, - shape, - units: str = "EPSG:4326", - encoding: str = "TILED", - output_path: pathlib.Path = None, - return_bytes: bool = False, - return_path: bool = False, - ): - """Extract ROI in world coordinates using a Shapely Polygon. - - Parameters - ---------- - shape - Anything shape-like (GeoJSON dict, WKT string, Shapely.Polygon) or - anything with a ``bounds`` property that returns the - bounding coordinates of the shape as: ``left``, ``bottom``, ``right``, - ``top``. - - """ - if not hasattr(shape, "bounds"): - shape = parse_shapely(shape) - left, bottom, right, top = shape.bounds - return self.extract_roi( - left, - right, - bottom, - top, - units=units, - encoding=encoding, - output_path=output_path, - return_bytes=return_bytes, - return_path=return_path, - ) - - def extract_roi_pixel( - self, - left: int, - right: int, - bottom: int, - top: int, - encoding: str = "TILED", - output_path: pathlib.Path = None, - return_bytes: bool = False, - return_path: bool = False, - ): - """Extract ROI in pixel coordinates.""" - raise NotImplementedError # pragma: no cover - - def metadata(self, projection: Optional[str] = ""): - raise NotImplementedError # pragma: no cover + @property + def max_zoom(self): + return self.info.maxzoom - def metadata_safe(self, projection: Optional[str] = ""): - if self.is_geospatial: - m = self.metadata(projection=projection) - else: - m = self.metadata(projection=None) - return m + @property + def default_zoom(self): + return self.min_zoom def bounds( self, projection: str = "EPSG:4326", return_polygon: bool = False, return_wkt: bool = False ): - """Get bounds in form of (ymin, ymax, xmin, xmax). - - Parameters - ---------- - projection : str - The EPSG projection of the returned coordinates. Can also be a - Proj4 projection. - - return_polygon : bool, optional - If true, return a shapely.Polygon object of the bounding polygon - of the raster. - - return_wkt : bool, optional - If true, return Well Known Text (WKT) string of the bounding - polygon of the raster. - - """ - raise NotImplementedError # pragma: no cover + bounds = get_source_bounds(self.reader, projection=projection) + extent = (bounds["bottom"], bounds["top"], bounds["left"], bounds["right"]) + if not return_polygon and not return_wkt: + return extent + # Safely import shapely + try: + from shapely.geometry import Polygon + except ImportError as e: # pragma: no cover + raise ImportError(f"Please install `shapely`: {e}") + coords = ( + (bounds["left"], bounds["top"]), + (bounds["left"], bounds["top"]), + (bounds["right"], bounds["top"]), + (bounds["right"], bounds["bottom"]), + (bounds["left"], bounds["bottom"]), + (bounds["left"], bounds["top"]), # Close the loop + ) + poly = Polygon(coords) + if return_wkt: + return poly.wkt + return poly def center( self, projection: str = "EPSG:4326", return_point: bool = False, return_wkt: bool = False @@ -377,443 +154,129 @@ def center( return point - def thumbnail( + def tile( self, - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, + z: int, + x: int, + y: int, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + nodata: Optional[Union[int, float]] = None, output_path: pathlib.Path = None, - style: dict = None, - cmap: Union[str, List[str]] = None, encoding: str = "PNG", + band: Union[int, List[int]] = None, ): - raise NotImplementedError # pragma: no cover - - def pixel(self, y: float, x: float, units: str = "pixels", projection: Optional[str] = None): - """Get pixel values for each band at the given coordinates (y , x ). + """Generate a tile from the source raster. Parameters ---------- - y : float - The Y coordinate (from top of image if `pixels` units or latitude if using EPSG) - x : float - The X coordinate (from left of image if `pixels` units or longitude if using EPSG) - units : str - The units of the coordinates (`pixels` or `EPSG:4326`). - projection : str, optional - The projection in which to open the image. + z : int + The zoom level of the tile. + x : int + The x coordinate of the tile. + y : int + The y coordinate of the tile. + indexes : int + The band of the source raster to use (default if None is to show RGB if + available). Band indexing starts at 1. This can also be a list of + integers to set which 3 bands to use for RGB. + colormap : str + The name of the matplotlib colormap to use when plotting a single band. + Default is greyscale. + vmin : float + The minimum value to use when colormapping a single band. + vmax : float + The maximized value to use when colormapping a single band. + nodata : float + The value from the band to use to interpret as not valid data. """ - raise NotImplementedError # pragma: no cover - - @property - def default_zoom(self): - m = self.metadata_safe() - try: - return m["levels"] - m["sourceLevels"] - except KeyError: - return 0 - - @property - def max_zoom(self): - m = self.metadata_safe() - return m.get("levels") - - @property - def is_geospatial(self): - if self._is_geospatial is None: - self._is_geospatial = self.metadata(projection=None).get("geospatial", False) - return self._is_geospatial - - if ipyleaflet: - - def _ipython_display_(self): - from IPython.display import display - from ipyleaflet import Map, WKTLayer, projections - - from localtileserver.widgets import get_leaflet_tile_layer - - t = get_leaflet_tile_layer(self) - if self.default_projection is None: - m = Map( - basemap=t, - min_zoom=0, - max_zoom=self.max_zoom, - zoom=0, - crs=projections.Simple, - ) - else: - m = Map(center=self.center(), zoom=self.default_zoom) - m.add_layer(t) - if shapely: - wlayer = WKTLayer( - wkt_string=self.bounds(return_wkt=True), - style={"dashArray": 9, "fillOpacity": 0, "weight": 1}, - ) - m.add_layer(wlayer) - return display(m) - - def _repr_png_(self): - with open(self.thumbnail(encoding="PNG"), "rb") as f: - return f.read() - - -class LocalTileClient(BaseTileClientInterface): - """Connect to a localtileserver instance. - - This is a base class for performing all operations locally. - - """ - - def __init__( - self, - filename: Union[pathlib.Path, str], - default_projection: Optional[str] = "EPSG:3857", - ): - super().__init__(filename, default_projection) - self._tile_source = get_tile_source(self.filename, self.default_projection) - - @property - def tile_source(self): - return self._tile_source - - @property - def rasterio(self): - return self._tile_source.dataset - - def get_tile( - self, - z: int, - x: int, - y: int, - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, - output_path: pathlib.Path = None, - style: dict = None, - cmap: Union[str, List[str]] = None, - encoding: str = "PNG", - ): + if indexes is None: + # TODO: properly deprecate + indexes = band if encoding.lower() not in ["png", "jpeg", "jpg"]: raise ValueError(f"Encoding ({encoding}) not supported.") encoding = format_to_encoding(encoding) - - if cmap is not None: - palette = cmap # simple alias - - if style is None: - style = make_style( - band, - palette, - vmin, - vmax, - nodata, - scheme, - n_colors, - ) - tile_source = get_tile_source( - self.filename, self.default_projection, style=style, encoding=encoding + tile_binary = get_tile( + self.reader, + z, + x, + y, + colormap=colormap, + indexes=indexes, + nodata=nodata, + img_format=encoding, + vmin=vmin, + vmax=vmax, ) - tile_binary = tile_source.getTile(x, y, z) - mimetype = tile_source.getTileMimeType() if output_path: with open(output_path, "wb") as f: f.write(tile_binary) - return ImageBytes(tile_binary, mimetype=mimetype) - - def extract_roi( - self, - left: float, - right: float, - bottom: float, - top: float, - units: str = "EPSG:4326", - encoding: str = "TILED", - output_path: pathlib.Path = None, - return_bytes: bool = False, - return_path: bool = False, - ): - path, mimetype = get_region_world( - self.tile_source, - left, - right, - bottom, - top, - units, - encoding, - ) - if output_path is not None: - shutil.move(path, output_path) - else: - output_path = path - if return_bytes: - with open(output_path, "rb") as f: - return ImageBytes(f.read(), mimetype=mimetype) - if return_path: - return output_path - return TileClient(output_path) - - def extract_roi_pixel( - self, - left: int, - right: int, - bottom: int, - top: int, - encoding: str = "TILED", - output_path: pathlib.Path = None, - return_bytes: bool = False, - return_path: bool = False, - ): - path, mimetype = get_region_pixel( - self.tile_source, - left, - right, - bottom, - top, - "pixels", - encoding, - ) - if output_path is not None: - shutil.move(path, output_path) - else: - output_path = path - if return_bytes: - with open(output_path, "rb") as f: - return ImageBytes(f.read(), mimetype=mimetype) - if return_path: - return output_path - return TileClient(output_path) - - def metadata(self, projection: Optional[str] = ""): - if projection not in self._metadata: - if projection == "": - projection = self.default_projection - tile_source = get_tile_source(self.filename, projection) - self._metadata[projection] = get_meta_data(tile_source) - return self._metadata[projection] - - def bounds( - self, projection: str = "EPSG:4326", return_polygon: bool = False, return_wkt: bool = False - ): - bounds = get_tile_bounds(self.tile_source, projection=projection) - extent = (bounds["ymin"], bounds["ymax"], bounds["xmin"], bounds["xmax"]) - if not return_polygon and not return_wkt: - return extent - # Safely import shapely - try: - from shapely.geometry import Polygon - except ImportError as e: # pragma: no cover - raise ImportError(f"Please install `shapely`: {e}") - coords = ( - (bounds["xmin"], bounds["ymax"]), - (bounds["xmin"], bounds["ymax"]), - (bounds["xmax"], bounds["ymax"]), - (bounds["xmax"], bounds["ymin"]), - (bounds["xmin"], bounds["ymin"]), - (bounds["xmin"], bounds["ymax"]), # Close the loop - ) - poly = Polygon(coords) - if return_wkt: - return poly.wkt - return poly + return tile_binary def thumbnail( self, - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + nodata: Optional[Union[int, float]] = None, output_path: pathlib.Path = None, - style: dict = None, - cmap: Union[str, List[str]] = None, encoding: str = "PNG", + max_size: int = 512, ): - if encoding.lower() not in ["png", "jpeg", "jpg", "tiff", "tif"]: - raise ValueError(f"Encoding ({encoding}) not supported.") - encoding = format_to_encoding(encoding) - - if cmap is not None: - palette = cmap # simple alias - - if style is None: - style = make_style( - band, - palette, - vmin, - vmax, - nodata, - scheme, - n_colors, - ) - tile_source = get_tile_source(self.filename, self.default_projection, style=style) - thumb_data, mimetype = tile_source.getThumbnail(encoding=encoding) - if output_path: - with open(output_path, "wb") as f: - f.write(thumb_data) - return ImageBytes(thumb_data, mimetype=mimetype) - - def pixel(self, y: float, x: float, units: str = "pixels"): - region = {"left": x, "top": y, "units": units} - return self.tile_source.getPixel(region=region) - - -class BaseRestfulTileClient(BaseTileClientInterface): - """Connect to a localtileserver instance. + """Generate a thumbnail preview of the dataset. - This is a base class for performing all operations over the RESTful API. - - """ - - def get_tile(self, z: int, x: int, y: int, *args, output_path=None, **kwargs): - url = self.get_tile_url(*args, **kwargs) - r = requests.get(url.format(z=z, x=x, y=y)) - r.raise_for_status() - if output_path: - return save_file_from_request(r, output_path) - return ImageBytes(r.content, mimetype=r.headers["Content-Type"]) - - def metadata(self, projection: Optional[str] = ""): - if projection not in self._metadata: - if projection == "": - projection = self.default_projection - r = requests.get(self.create_url(f"/api/metadata?projection={projection}")) - r.raise_for_status() - self._metadata[projection] = r.json() - return self._metadata[projection] - - def bounds( - self, projection: str = "EPSG:4326", return_polygon: bool = False, return_wkt: bool = False - ): - r = requests.get( - self.create_url(f"/api/bounds?units={projection}&projection={self.default_projection}") - ) - r.raise_for_status() - bounds = r.json() - extent = (bounds["ymin"], bounds["ymax"], bounds["xmin"], bounds["xmax"]) - if not return_polygon and not return_wkt: - return extent - # Safely import shapely - try: - from shapely.geometry import Polygon - except ImportError as e: # pragma: no cover - raise ImportError(f"Please install `shapely`: {e}") - coords = ( - (bounds["xmin"], bounds["ymax"]), - (bounds["xmin"], bounds["ymax"]), - (bounds["xmax"], bounds["ymax"]), - (bounds["xmax"], bounds["ymin"]), - (bounds["xmin"], bounds["ymin"]), - (bounds["xmin"], bounds["ymax"]), # Close the loop - ) - poly = Polygon(coords) - if return_wkt: - return poly.wkt - return poly + Parameters + ---------- + indexes : int + The band of the source raster to use (default if None is to show RGB if + available). Band indexing starts at 1. This can also be a list of + integers to set which 3 bands to use for RGB. + colormap : str + The name of the matplotlib colormap to use when plotting a single band. + Default is greyscale. + vmin : float + The minimum value to use when colormapping a single band. + vmax : float + The maximized value to use when colormapping a single band. + nodata : float + The value from the band to use to interpret as not valid data. - def thumbnail( - self, - band: Union[int, List[int]] = None, - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, - output_path: pathlib.Path = None, - style: dict = None, - cmap: Union[str, List[str]] = None, - encoding: str = "PNG", - ): - if encoding.lower() not in ["png", "jpeg", "jpg", "tiff", "tif"]: + """ + if encoding.lower() not in ["png", "jpeg", "jpg"]: raise ValueError(f"Encoding ({encoding}) not supported.") - params = self._get_style_params( - band=band, - palette=palette, + encoding = format_to_encoding(encoding) + thumb_data = get_preview( + self.reader, + max_size=max_size, + colormap=colormap, + indexes=indexes, + nodata=nodata, + img_format=encoding, vmin=vmin, vmax=vmax, - nodata=nodata, - scheme=scheme, - n_colors=n_colors, - style=style, - cmap=cmap, ) - url = add_query_parameters(self.create_url(f"api/thumbnail.{encoding.lower()}"), params) - r = requests.get(url) - r.raise_for_status() - if output_path: - return save_file_from_request(r, output_path) - return ImageBytes(r.content, mimetype=r.headers["Content-Type"]) - - def pixel(self, y: float, x: float, units: str = "pixels", projection: Optional[str] = None): - params = {} - params["x"] = x - params["y"] = y - params["units"] = units - if projection: - params["projection"] = projection - url = add_query_parameters(self.create_url("api/pixel"), params) - r = requests.get(url) - r.raise_for_status() - return r.json() - - -class RemoteTileClient(BaseRestfulTileClient): - """Connect to a remote localtileserver instance at a given host URL. - Parameters - ---------- - path : pathlib.Path, str - The path on disk to use as the source raster for the tiles. - host : str - The base URL of your remote localtileserver instance. - - """ - - def __init__( - self, - filename: Union[pathlib.Path, str], - default_projection: Optional[str] = "EPSG:3857", - host: str = None, - ): - super().__init__(filename=filename, default_projection=default_projection) - if host is None: - host = DEMO_REMOTE_TILE_SERVER - logger.error( - "WARNING: You are using a demo instance of localtileserver that has incredibly limited resources: it is unreliable and prone to crash. Please launch your own remote instance of localtileserver." - ) - self._host = host - - @property - def server_host(self): - return self._host + if output_path: + with open(output_path, "wb") as f: + f.write(thumb_data) + return thumb_data - @server_host.setter - def server_host(self, host): - self._host = host + def point(self, lon: float, lat: float, **kwargs): + return get_point(self.reader, lon, lat, **kwargs) - @property - def server_base_url(self): - return self.server_host + def _repr_png_(self): + return self.thumbnail(encoding="png") -class BaseTileClient: +class TileServerMixin: """Serve tiles from a local raster file in a background thread. Parameters ---------- - path : pathlib.Path, str, rasterio.io.DatasetReaderBase - The path on disk to use as the source raster for the tiles. port : int The port on your host machine to use for the tile server. This defaults to getting an available port. @@ -829,8 +292,6 @@ class BaseTileClient: def __init__( self, - filename: Union[pathlib.Path, str, rasterio.io.DatasetReaderBase, RasterioFileTileSource], - default_projection: Optional[str] = "EPSG:3857", port: Union[int, str] = "default", debug: bool = False, host: str = "127.0.0.1", @@ -839,11 +300,6 @@ def __init__( client_prefix: str = None, cors_all: bool = False, ): - if isinstance(filename, rasterio.io.DatasetReaderBase) and hasattr(filename, "name"): - filename = filename.name - elif isinstance(filename, RasterioFileTileSource): - filename = filename._getLargeImagePath() - super().__init__(filename=filename, default_projection=default_projection) app = AppManager.get_or_create_app(cors_all=cors_all) self._key = launch_server(app, port=port, debug=debug, host=host) # Store actual port just in case @@ -858,13 +314,9 @@ def __init__( self._client_host = DEMO_REMOTE_TILE_SERVER if not debug: - logging.getLogger("gdal").setLevel(logging.ERROR) - logging.getLogger("large_image").setLevel(logging.ERROR) + logging.getLogger("rasterio").setLevel(logging.ERROR) else: - logging.getLogger("gdal").setLevel(logging.DEBUG) - logging.getLogger("large_image").setLevel(logging.DEBUG) - logging.getLogger("large_image_source_gdal").setLevel(logging.DEBUG) - logging.getLogger("large_image_source_rasterio").setLevel(logging.DEBUG) + logging.getLogger("rasterio").setLevel(logging.DEBUG) try: import google.colab # noqa @@ -949,6 +401,9 @@ def client_base_url(self): base = f"/{base.lstrip('/')}" return base + def _produce_url(self, base: str): + return add_query_parameters(base, {"filename": self.filename}) + def create_url(self, path: str, client: bool = False): if client and ( self.client_port is not None @@ -958,29 +413,136 @@ def create_url(self, path: str, client: bool = False): return self._produce_url(f"{self.client_base_url}/{path.lstrip('/')}") return self._produce_url(f"{self.server_base_url}/{path.lstrip('/')}") - @wraps(BaseTileClientInterface.get_tile_url_params) - def get_tile_url(self, *args, client: bool = False, **kwargs): - params = self.get_tile_url_params(*args, **kwargs) + def get_tile_url( + self, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + nodata: Optional[Union[int, float]] = None, + client: bool = False, + ): + """Get slippy maps tile URL (e.g., `/zoom/x/y.png`). + + Parameters + ---------- + indexes : int + The band of the source raster to use (default if None is to show RGB if + available). Band indexing starts at 1. This can also be a list of + integers to set which 3 bands to use for RGB. + colormap : str + The name of the matplotlib colormap to use when plotting a single band. + Default is greyscale. + vmin : float + The minimum value to use when colormapping a single band. + vmax : float + The maximized value to use when colormapping a single band. + nodata : float + The value from the band to use to interpret as not valid data. + + """ + # First handle query parameters to check for errors + params = {} + if indexes is not None: + params["indexes"] = indexes + if colormap is not None: + # make sure palette is valid + palette_valid_or_raise(colormap) + params["colormap"] = colormap + if vmin is not None: + if isinstance(vmin, Iterable) and not isinstance(indexes, Iterable): + raise ValueError("`indexes` must be explicitly set if `vmin` is an iterable.") + params["min"] = vmin + if vmax is not None: + if isinstance(vmax, Iterable) and not isinstance(indexes, Iterable): + raise ValueError("`indexes` must be explicitly set if `vmax` is an iterable.") + params["max"] = vmax + if nodata is not None: + if isinstance(nodata, Iterable) and not isinstance(indexes, Iterable): + raise ValueError("`indexes` must be explicitly set if `nodata` is an iterable.") + params["nodata"] = nodata return add_query_parameters( self.create_url("api/tiles/{z}/{x}/{y}.png", client=client), params ) + def as_leaflet_layer(self): + from localtileserver.widgets import get_leaflet_tile_layer + + return get_leaflet_tile_layer(self) + + if ipyleaflet: + + def _ipython_display_(self): + from IPython.display import display + from ipyleaflet import Map, WKTLayer + + from localtileserver.widgets import get_leaflet_tile_layer + + t = get_leaflet_tile_layer(self) + m = Map(center=self.center(), zoom=self.default_zoom) + m.add(t) + if shapely: + wlayer = WKTLayer( + wkt_string=self.bounds(return_wkt=True), + style={"dashArray": 9, "fillOpacity": 0, "weight": 1}, + ) + m.add(wlayer) + return display(m) + -class TileClient(BaseTileClient, LocalTileClient): - pass +class TileClient(TilerInterface, TileServerMixin): + """Tile client interface for generateing and serving tiles. + + Parameters + ---------- + source : pathlib.Path, str, Reader, DatasetReaderBase + The source dataset to use for the tile client. + port : int + The port on your host machine to use for the tile server. This defaults + to getting an available port. + debug : bool + Run the tile server in debug mode. + client_port : int + The port on your client browser to use for fetching tiles. This is + useful when running in Docker and performing port forwarding. + client_host : str + The host on which your client browser can access the server. + """ -class RestTileClient(BaseTileClient, BaseRestfulTileClient): - pass + def __init__( + self, + source: Union[pathlib.Path, str, rasterio.io.DatasetReaderBase], + port: Union[int, str] = "default", + debug: bool = False, + host: str = "127.0.0.1", + client_port: int = None, + client_host: str = None, + client_prefix: str = None, + cors_all: bool = False, + ): + TilerInterface.__init__(self, source=source) + TileServerMixin.__init__( + self, + port=port, + debug=debug, + host=host, + client_port=client_port, + client_host=client_host, + client_prefix=client_prefix, + cors_all=cors_all, + ) def get_or_create_tile_client( source: Union[ - pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase, RasterioFileTileSource + pathlib.Path, + str, + TileClient, + rasterio.io.DatasetReaderBase, ], port: Union[int, str] = "default", debug: bool = False, - default_projection: Optional[str] = "EPSG:3857", ): """A helper to safely get a TileClient from a path on disk. @@ -991,12 +553,10 @@ def get_or_create_tile_client( default is for all TileClient's to share a single server. """ - if isinstance(source, RemoteTileClient): - return source, False _internally_created = False # Launch tile server if file path is given if not isinstance(source, TileClient): - source = TileClient(source, port=port, debug=debug, default_projection=default_projection) + source = TileClient(source, port=port, debug=debug) _internally_created = True # Check that the tile source is valid and no server errors diff --git a/localtileserver/configure.py b/localtileserver/configure.py index ff70ab7d..86303979 100644 --- a/localtileserver/configure.py +++ b/localtileserver/configure.py @@ -1,4 +1,3 @@ -# flake8: noqa: W503 import os diff --git a/localtileserver/helpers.py b/localtileserver/helpers.py index 9a7934f3..300da333 100644 --- a/localtileserver/helpers.py +++ b/localtileserver/helpers.py @@ -63,7 +63,7 @@ def save_new_raster(src, data, out_path: str = None): Parameters ---------- - src : str, DatasetReader, BaseTileClientInterface + src : str, DatasetReader, TilerInterface The source rasterio data whose spatial reference will be copied data : np.ndarray The bands of data to save to the new raster @@ -72,15 +72,15 @@ def save_new_raster(src, data, out_path: str = None): use a temporary file """ - from localtileserver.client import BaseTileClientInterface + from localtileserver.client import TilerInterface if data.ndim == 2: data = data.reshape((1, *data.shape)) if data.ndim != 3: raise AssertionError("data must be ndim 3: (bands, height, width)") - if isinstance(src, BaseTileClientInterface): - src = src.rasterio + if isinstance(src, TilerInterface): + src = src.dataset if isinstance(src, rasterio.io.DatasetReaderBase): ras_meta = src.meta.copy() else: diff --git a/localtileserver/report.py b/localtileserver/report.py index 7a22df6b..81a1e62b 100644 --- a/localtileserver/report.py +++ b/localtileserver/report.py @@ -3,17 +3,13 @@ class Report(scooby.Report): def __init__(self, additional=None, ncol=3, text_width=80, sort=False): - """Initiate a scooby.Report instance.""" + """Generate a report on the dependencies of localtileserver in this environment.""" # Mandatory packages. - large_image_core = [ - "large_image", - "large_image_source_rasterio", - "cachetools", - "PIL", - "psutil", + rio_tiler_core = [ + "rasterio", + "rio_tiler", "numpy", - "palettable", ] core = [ "localtileserver", @@ -22,16 +18,16 @@ def __init__(self, additional=None, ncol=3, text_width=80, sort=False): "flask_cors", "flask_restx", "requests", + "rio_cogeo", "werkzeug", "click", "server_thread", "scooby", - ] + large_image_core + ] + rio_tiler_core # Optional packages. optional = [ "gunicorn", - "pylibmc", "ipyleaflet", "jupyterlab", "jupyter_server_proxy", @@ -41,16 +37,6 @@ def __init__(self, additional=None, ncol=3, text_width=80, sort=False): "matplotlib", "colorcet", "cmocean", - "large_image_source_gdal", - "large_image_source_mapnik", - "large_image_source_pil", - "large_image_source_tiff", - "large_image_converter", - "tifftools", - "pyvips", - "pylibtiff", - "osgeo.gdal", - "pyproj", ] scooby.Report.__init__( diff --git a/localtileserver/tiler/__init__.py b/localtileserver/tiler/__init__.py index 5dad29e0..f81e1ba7 100644 --- a/localtileserver/tiler/__init__.py +++ b/localtileserver/tiler/__init__.py @@ -9,19 +9,20 @@ get_sf_bay_url, str_to_bool, ) +from localtileserver.tiler.handler import ( + get_meta_data, + get_point, + get_preview, + get_reader, + get_source_bounds, + get_tile, +) from localtileserver.tiler.palettes import get_palettes, palette_valid_or_raise -from localtileserver.tiler.style import make_style from localtileserver.tiler.utilities import ( + ImageBytes, format_to_encoding, get_cache_dir, get_clean_filename, - get_memcache_config, - get_meta_data, - get_region_pixel, - get_region_world, - get_tile_bounds, - get_tile_source, - is_geospatial, make_vsi, purge_cache, ) diff --git a/localtileserver/tiler/data/__init__.py b/localtileserver/tiler/data/__init__.py index 1707a95c..da871dc0 100644 --- a/localtileserver/tiler/data/__init__.py +++ b/localtileserver/tiler/data/__init__.py @@ -1,4 +1,3 @@ -# flake8: noqa: W503 import os import pathlib diff --git a/localtileserver/tiler/data/bahamas_rgb.tif b/localtileserver/tiler/data/bahamas_rgb.tif index fa1c0317..69e52ae6 100644 Binary files a/localtileserver/tiler/data/bahamas_rgb.tif and b/localtileserver/tiler/data/bahamas_rgb.tif differ diff --git a/localtileserver/tiler/handler.py b/localtileserver/tiler/handler.py new file mode 100644 index 00000000..22024f30 --- /dev/null +++ b/localtileserver/tiler/handler.py @@ -0,0 +1,194 @@ +"""Methods for working with images.""" +import pathlib +from typing import List, Optional, Union + +import numpy as np +import rasterio +from rasterio.enums import ColorInterp +from rio_tiler.colormap import cmap +from rio_tiler.io import Reader +from rio_tiler.models import ImageData + +from .utilities import ImageBytes, get_clean_filename, make_crs + +# gdal.SetConfigOption("GDAL_ENABLE_WMS_CACHE", "YES") +# gdal.SetConfigOption("GDAL_DEFAULT_WMS_CACHE_PATH", str(get_cache_dir() / "gdalwmscache")) +# TODO: what's the other option for directories on S3? +# TODO: should I set these in a rasterio.Env? + + +def get_reader(path: Union[pathlib.Path, str]) -> Reader: + return Reader(get_clean_filename(path)) + + +def get_meta_data(tile_source: Reader): + metadata = { + **tile_source.info().model_dump(), + **tile_source.dataset.meta, + } + crs = metadata["crs"].to_wkt() if hasattr(metadata["crs"], "to_wkt") else None + metadata.update(crs=crs, transform=list(metadata["transform"])) + if crs: + metadata["bounds"] = get_source_bounds(tile_source) + return metadata + + +def get_source_bounds(tile_source: Reader, projection: str = "EPSG:4326", decimal_places: int = 6): + src_crs = tile_source.dataset.crs + if not src_crs: + return { + "left": -180.0, + "bottom": -90.0, + "right": 180.0, + "top": 90.0, + } + dst_crs = make_crs(projection) + left, bottom, right, top = rasterio.warp.transform_bounds( + src_crs, dst_crs, *tile_source.dataset.bounds + ) + return { + "left": round(left, decimal_places), + "bottom": round(bottom, decimal_places), + "right": round(right, decimal_places), + "top": round(top, decimal_places), + # west, south, east, north + # "west": round(left, decimal_places), + # "south": round(bottom, decimal_places), + # "east": round(right, decimal_places), + # "north": round(top, decimal_places), + } + + +def _handle_band_indexes(tile_source: Reader, indexes: Optional[List[int]] = None): + if not indexes: + RGB_INTERPRETATIONS = [ColorInterp.red, ColorInterp.green, ColorInterp.blue] + RGB_DESCRIPTORS = ["red", "green", "blue"] + if set(RGB_INTERPRETATIONS).issubset(set(tile_source.dataset.colorinterp)): + indexes = [tile_source.dataset.colorinterp.index(i) + 1 for i in RGB_INTERPRETATIONS] + elif set(RGB_DESCRIPTORS).issubset(set(tile_source.dataset.descriptions)): + indexes = [tile_source.dataset.descriptions.index(i) + 1 for i in RGB_DESCRIPTORS] + elif len(tile_source.dataset.indexes) >= 3: + indexes = [1, 2, 3] + elif len(tile_source.dataset.indexes) < 3: + indexes = [1] + else: + raise ValueError("Could not determine band indexes") + else: + if isinstance(indexes, str): + indexes = int(indexes) + if isinstance(indexes, int): + indexes = [indexes] + if isinstance(indexes, list): + indexes = [int(i) for i in indexes] + return indexes + + +def _handle_nodata(tile_source: Reader, nodata: Optional[Union[int, float]] = None): + floaty = False + if any(dtype.startswith("float") for dtype in tile_source.dataset.dtypes): + floaty = True + if floaty and nodata is None and tile_source.dataset.nodata is not None: + nodata = np.nan + elif nodata is not None: + if isinstance(nodata, str): + nodata = float(nodata) + return nodata + + +def _render_image( + tile_source: Reader, + img: ImageData, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + img_format: str = "PNG", +): + if isinstance(vmin, str): + vmin = float(vmin) + if isinstance(vmax, str): + vmax = float(vmax) + colormap = cmap.get(colormap) if colormap else None + if ( + not colormap + and len(indexes) == 1 + and tile_source.dataset.colorinterp[indexes[0] - 1] == ColorInterp.palette + ): + colormap = tile_source.dataset.colormap(indexes[0]) + elif img.data.dtype != np.dtype("uint8") or vmin is not None or vmax is not None: + stats = tile_source.statistics() + in_range = [ + (s.min if vmin is None else vmin, s.max if vmax is None else vmax) + for s in stats.values() + ] + img.rescale( + in_range=in_range, + out_range=[(0, 255)], + ) + return ImageBytes( + img.render(img_format=img_format, colormap=colormap if colormap else None), + mimetype=f"image/{img_format.lower()}", + ) + + +def get_tile( + tile_source: Reader, + z: int, + x: int, + y: int, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + nodata: Optional[Union[int, float]] = None, + img_format: str = "PNG", +): + if colormap is not None and indexes is None: + indexes = [1] + indexes = _handle_band_indexes(tile_source, indexes) + nodata = _handle_nodata(tile_source, nodata) + img = tile_source.tile(x, y, z, indexes=indexes, nodata=nodata) + return _render_image( + tile_source, + img, + indexes=indexes, + colormap=colormap, + img_format=img_format, + vmin=vmin, + vmax=vmax, + ) + + +def get_point( + tile_source: Reader, + lon: float, + lat: float, + **kwargs, +): + return tile_source.point(lon, lat, **kwargs) + + +def get_preview( + tile_source: Reader, + indexes: Optional[List[int]] = None, + colormap: Optional[str] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + nodata: Optional[Union[int, float]] = None, + img_format: str = "PNG", + max_size: int = 512, +): + if colormap is not None and indexes is None: + indexes = [1] + indexes = _handle_band_indexes(tile_source, indexes) + nodata = _handle_nodata(tile_source, nodata) + img = tile_source.preview(max_size=max_size, indexes=indexes, nodata=nodata) + return _render_image( + tile_source, + img, + indexes=indexes, + colormap=colormap, + img_format=img_format, + vmin=vmin, + vmax=vmax, + ) diff --git a/localtileserver/tiler/palettes.py b/localtileserver/tiler/palettes.py index 1fe547d5..2b24efd5 100644 --- a/localtileserver/tiler/palettes.py +++ b/localtileserver/tiler/palettes.py @@ -1,8 +1,4 @@ import logging -from operator import attrgetter -import re - -import palettable try: import cmocean # noqa @@ -15,30 +11,6 @@ logger = logging.getLogger(__name__) -SIMPLE_PALETTES = { - "red": ["#000", "#f00"], - "r": ["#000", "#f00"], - "green": ["#000", "#0f0"], - "g": ["#000", "#0f0"], - "blue": ["#000", "#00f"], - "b": ["#000", "#00f"], -} - - -def is_hex_str(color: str): - """Check if str is hex color.""" - if re.search(r"^#(?:[0-9a-fA-F]{3}){1,2}$", color): - return True - return False - - -def is_palettable_palette(name: str): - try: - attrgetter(name)(palettable) - except AttributeError: - return False - return True - def is_mpl_cmap(name: str): """This will silently fail if matplotlib is not installed.""" @@ -54,59 +26,13 @@ def is_mpl_cmap(name: str): return False -def is_valid_palette_name(name: str): - return is_palettable_palette(name) or name in SIMPLE_PALETTES or is_mpl_cmap(name) - - def palette_valid_or_raise(name: str): - status = False - if isinstance(name, str): - status = is_valid_palette_name(name) - elif isinstance(name, (list, tuple)): - status = all([is_valid_palette_name(p) for p in name]) or all([is_hex_str(p) for p in name]) - if not status: - raise ValueError( - f"Please use a valid matplotlib colormap name or palettable palette name. Invalid: {name}" - ) - - -def mpl_to_palette(cmap: str, n_colors: int = 255): - """Convert Matplotlib colormap to a palette.""" - import matplotlib - import matplotlib.colors as mcolors - - # TODO: this is deprecated but it isn't obvious how to do with new API - cmap = matplotlib.cm.get_cmap(cmap, n_colors) - color_list = [mcolors.rgb2hex(cmap(i)) for i in range(cmap.N)] - return color_list - - -def get_palette_by_name(name: str, n_colors: int = 255): - """Get a palette by name. - - This supports matplotlib colormaps and palettable palettes. - - If the palette is a valid palettable name, return that name. Otherwise, - this will generate a full palette from the given name. - - ``n_colors`` is only used if fetching a Matplotlib colormap. - """ - palette_valid_or_raise(name) - if is_palettable_palette(name): - return name - if name in SIMPLE_PALETTES: - return SIMPLE_PALETTES[name] - if is_mpl_cmap(name): - return mpl_to_palette(name, n_colors=n_colors) + if not is_mpl_cmap(name): + raise ValueError(f"Please use a valid matplotlib colormap name. Invalid: {name}") def get_palettes(): - """List of available palettes. - - This does not currently list the palettable palettes there isn't a clean - way to list all of them. - - """ + """List of available palettes.""" cmaps = {} try: import matplotlib.pyplot @@ -114,5 +40,4 @@ def get_palettes(): cmaps["matplotlib"] = list(matplotlib.pyplot.colormaps()) except ImportError: # pragma: no cover logger.error("Install matplotlib for additional colormap choices.") - cmaps["simple"] = [s for s in SIMPLE_PALETTES.keys() if len(s) > 1] return cmaps diff --git a/localtileserver/tiler/style.py b/localtileserver/tiler/style.py deleted file mode 100644 index 6b1dbe38..00000000 --- a/localtileserver/tiler/style.py +++ /dev/null @@ -1,111 +0,0 @@ -from collections.abc import Iterable -import json -import logging -from typing import Any, List, Union - -from localtileserver.tiler.palettes import get_palette_by_name - -logger = logging.getLogger(__name__) - - -def reformat_style_query_parameters(args: dict): - out = {} - for k, v in args.items(): - name = k.split(".")[0] - if name in out: - out[name].append(v) - else: - out.setdefault(name, [v]) - # If not multiple, remove list - for k, v in out.items(): - if len(v) == 1: - out[k] = v[0] - return out - - -def make_single_band_style( - band: int, - vmin: Union[int, float] = None, - vmax: Union[int, float] = None, - palette: Union[str, List[str]] = None, - nodata: Union[int, float] = None, - scheme: str = None, - n_colors: int = 255, -): - style = None - if isinstance(band, (int, str)): - band = int(band) - # Check for 0-index: - if band == 0: - raise ValueError("0 is an invalid band index. Bands start at 1.") - style = {"band": band} - if vmin is not None: - style["min"] = vmin - if vmax is not None: - style["max"] = vmax - if nodata is not None: - style["nodata"] = float(nodata) - if palette: - if isinstance(palette, str): - style["palette"] = get_palette_by_name(palette, n_colors=n_colors) - else: - # TODO: check contents to make sure its a list of valid HEX colors - style["palette"] = palette - if scheme is not None: - style["scheme"] = scheme - return style - - -def safe_get(obj: Any, index: int): - if isinstance(obj, (list, tuple)): - try: - return obj[index] - except (TypeError, IndexError): - return None - return obj - - -def make_style( - band: Union[int, List[int]], - palette: Union[str, List[str]] = None, - vmin: Union[Union[float, int], List[Union[float, int]]] = None, - vmax: Union[Union[float, int], List[Union[float, int]]] = None, - nodata: Union[Union[float, int], List[Union[float, int]]] = None, - scheme: Union[str, List[str]] = None, - n_colors: int = 255, -): - style = None - # Handle when user sets min/max/etc. but forgot band. Default to 1 - if not band and any(v is not None for v in [vmin, vmax, palette, nodata]): - band = 1 - elif band == 0: - return - - if isinstance(band, (int, str)): - # Handle viewing single band - style = make_single_band_style( - band, - vmin=vmin, - vmax=vmax, - palette=palette, - nodata=nodata, - scheme=scheme, - n_colors=n_colors, - ) - elif isinstance(band, Iterable): - # Handle viewing multiple bands together - style = {"bands": []} - if palette is None and len(band) == 3: - # Handle setting RGB by default - palette = ["r", "g", "b"] - for i, b in enumerate(band): - vmi = safe_get(vmin, i) - vma = safe_get(vmax, i) - p = safe_get(palette, i) - nod = safe_get(nodata, i) - style["bands"].append( - make_single_band_style(b, vmin=vmi, vmax=vma, palette=p, nodata=nod, scheme=scheme), - ) - # Return JSON encoded - if style: - return json.dumps(style) diff --git a/localtileserver/tiler/utilities.py b/localtileserver/tiler/utilities.py index 9dc16a17..909037f0 100644 --- a/localtileserver/tiler/utilities.py +++ b/localtileserver/tiler/utilities.py @@ -1,43 +1,39 @@ -import logging import os import pathlib import shutil import tempfile -from typing import Optional, Union +from typing import Optional from urllib.parse import urlencode, urlparse -import large_image -from large_image_source_rasterio import RasterioFileTileSource from rasterio import CRS from localtileserver.tiler.data import clean_url, get_data_path, get_pine_gulch_url -logger = logging.getLogger(__name__) +class ImageBytes(bytes): + """Wrapper class to make repr of image bytes better in ipython.""" -def get_memcache_config(): - url, username, password = None, None, None - if os.environ.get("MEMCACHED_URL", ""): - url = os.environ.get("MEMCACHED_URL") - if os.environ.get("MEMCACHED_USERNAME", "") and os.environ.get("MEMCACHED_PASSWORD", ""): - username = os.environ.get("MEMCACHED_USERNAME") - password = os.environ.get("MEMCACHED_PASSWORD") - elif os.environ.get("MEMCACHIER_SERVERS", ""): - url = os.environ.get("MEMCACHIER_SERVERS") - if os.environ.get("MEMCACHIER_USERNAME", "") and os.environ.get("MEMCACHIER_PASSWORD", ""): - username = os.environ.get("MEMCACHIER_USERNAME") - password = os.environ.get("MEMCACHIER_PASSWORD") - return url, username, password + def __new__(cls, source: bytes, mimetype: str = None): + self = super().__new__(cls, source) + self._mime_type = mimetype + return self + @property + def mimetype(self): + return self._mime_type -def configure_large_image_memcache(url: str, username: str = None, password: str = None): - if url: - large_image.config.setConfig("cache_memcached_url", url) - if username and password: - large_image.config.setConfig("cache_memcached_username", username) - large_image.config.setConfig("cache_memcached_password", password) - large_image.config.setConfig("cache_backend", "memcached") - logger.info("large_image is configured for memcached.") + def _repr_png_(self): + if self.mimetype == "image/png": + return self + + def _repr_jpeg_(self): + if self.mimetype == "image/jpeg": + return self + + def __repr__(self): + if self.mimetype: + return f"ImageBytes<{len(self)}> ({self.mimetype})" + return f"ImageBytes<{len(self)}> (wrapped image bytes)" def get_cache_dir(): @@ -46,11 +42,6 @@ def get_cache_dir(): return path -configure_large_image_memcache(*get_memcache_config()) -# gdal.SetConfigOption("GDAL_ENABLE_WMS_CACHE", "YES") -# gdal.SetConfigOption("GDAL_DEFAULT_WMS_CACHE_PATH", str(get_cache_dir() / "gdalwmscache")) - - def purge_cache(): """Completely purge all files from the file cache. @@ -62,89 +53,6 @@ def purge_cache(): return get_cache_dir() -def is_geospatial(source: RasterioFileTileSource) -> bool: - return source.getMetadata().get("geospatial", False) - - -def get_tile_source( - path: Union[pathlib.Path, str], projection: str = None, style: str = None, encoding: str = "PNG" -) -> RasterioFileTileSource: - path = get_clean_filename(path) - return RasterioFileTileSource(str(path), projection=projection, style=style, encoding=encoding) - - -def _get_region(tile_source: RasterioFileTileSource, region: dict, encoding: str): - result, mime_type = tile_source.getRegion(region=region, encoding=encoding) - if encoding == "TILED": - path = result - else: - # Write content to temporary file - fd, path = tempfile.mkstemp( - suffix=f".{encoding}", prefix="pixelRegion_", dir=str(get_cache_dir()) - ) - os.close(fd) - path = pathlib.Path(path) - with open(path, "wb") as f: - f.write(result) - return path, mime_type - - -def get_region_world( - tile_source: RasterioFileTileSource, - left: float, - right: float, - bottom: float, - top: float, - units: str = "EPSG:4326", - encoding: str = "TILED", -): - region = dict(left=left, right=right, bottom=bottom, top=top, units=units) - return _get_region(tile_source, region, encoding) - - -def get_region_pixel( - tile_source: RasterioFileTileSource, - left: int, - right: int, - bottom: int, - top: int, - units: str = "pixels", - encoding: str = None, -): - left, right = min(left, right), max(left, right) - top, bottom = min(top, bottom), max(top, bottom) - region = dict(left=left, right=right, bottom=bottom, top=top, units=units) - if encoding is None: - # Use tiled encoding by default for geospatial rasters - # output will be a tiled TIF - encoding = "TILED" - return _get_region(tile_source, region, encoding) - - -def get_tile_bounds( - tile_source: RasterioFileTileSource, - projection: str = "EPSG:4326", -): - if not is_geospatial(tile_source): - return {"xmin": 0, "xmax": tile_source.sizeX, "ymin": 0, "ymax": tile_source.sizeY} - bounds = tile_source.getBounds(srs=projection) - if projection == "EPSG:4326": - threshold = 89.9999 - for key in ("ymin", "ymax"): - bounds[key] = max(min(bounds[key], threshold), -threshold) - return bounds - - -def get_meta_data(tile_source: RasterioFileTileSource): - meta = tile_source.getMetadata() - meta.update(tile_source.getInternalMetadata()) - # Override bounds for EPSG:4326 - meta["bounds"] = get_tile_bounds(tile_source) - if "projection" in meta and isinstance(meta["projection"], CRS): - meta["projection"] = meta["projection"].to_string() - return meta - - def make_vsi(url: str, **options): url = clean_url(url) if str(url).startswith("s3://"): @@ -192,13 +100,21 @@ def get_clean_filename(filename: str): def format_to_encoding(fmt: Optional[str]) -> str: - """Translate format extension (e.g., `tiff`) to encoding (e.g., `TILED`).""" + """Validate encoding.""" if not fmt: - return "PNG" - if fmt.lower() not in ["tif", "tiff", "png", "jpeg", "jpg"]: - raise ValueError(f"Format {fmt!r} is not valid. Try `png`, `jpeg`, or `tif`") - if fmt.lower() in ["tif", "tiff"]: - return "TILED" + return "png" + if fmt.lower() not in ["png", "jpeg", "jpg"]: + raise ValueError(f"Format {fmt!r} is not valid. Try `png` or `jpeg`") if fmt.lower() == "jpg": fmt = "jpeg" - return fmt.upper() # jpeg, png + return fmt.upper() # PNG, JPEG + + +def make_crs(projection): + if isinstance(projection, str): + return CRS.from_string(projection) + if isinstance(projection, dict): + return CRS.from_dict(projection) + if isinstance(projection, int): + return CRS.from_string(f"EPSG:{projection}") + return CRS(projection) diff --git a/localtileserver/utilities.py b/localtileserver/utilities.py index ad55ac65..680ef717 100644 --- a/localtileserver/utilities.py +++ b/localtileserver/utilities.py @@ -7,32 +7,6 @@ from localtileserver.tiler import get_cache_dir -class ImageBytes(bytes): - """Wrapper class to make repr of image bytes better in ipython.""" - - def __new__(cls, source: bytes, mimetype: str = None): - self = super().__new__(cls, source) - self._mime_type = mimetype - return self - - @property - def mimetype(self): - return self._mime_type - - def _repr_png_(self): - if self.mimetype == "image/png": - return self - - def _repr_jpeg_(self): - if self.mimetype == "image/jpeg": - return self - - def __repr__(self): - if self.mimetype: - return f"ImageBytes<{len(self)}> ({self.mimetype})" - return f"ImageBytes<{len(self)}> (wrapped image bytes)" - - def save_file_from_request(response: requests.Response, output_path: pathlib.Path): d = response.headers["content-disposition"] fname = re.findall("filename=(.+)", d)[0] diff --git a/localtileserver/validate.py b/localtileserver/validate.py index 2e834784..64bd14ba 100644 --- a/localtileserver/validate.py +++ b/localtileserver/validate.py @@ -1,26 +1,21 @@ -import logging from typing import Union -import large_image -from large_image_source_rasterio import RasterioFileTileSource +from rio_cogeo import cog_validate +from rio_tiler.io import Reader -from localtileserver.client import BaseTileClientInterface +from localtileserver.client import TilerInterface from localtileserver.tiler import get_clean_filename -logger = logging.getLogger(__name__) - def validate_cog( - path: Union[str, RasterioFileTileSource, BaseTileClientInterface], + path: Union[str, Reader, TilerInterface], strict: bool = True, - warn: bool = True, -): - if isinstance(path, RasterioFileTileSource): - src = path - elif isinstance(path, BaseTileClientInterface): + quiet: bool = False, +) -> bool: + if isinstance(path, Reader): + path = path.dataset.name + elif isinstance(path, TilerInterface): path = path.filename - src = large_image.open(path) else: path = get_clean_filename(path) - src = large_image.open(path) - return src.validateCOG(strict=strict, warn=warn) + return cog_validate(path, strict=strict, quiet=quiet)[0] diff --git a/localtileserver/web/application.py b/localtileserver/web/application.py index 3c032096..b68fa13b 100644 --- a/localtileserver/web/application.py +++ b/localtileserver/web/application.py @@ -27,13 +27,13 @@ def create_app( app.config.JSONIFY_PRETTYPRINT_REGULAR = True app.config.SWAGGER_UI_DOC_EXPANSION = "list" app.config["DEBUG"] = debug + cesium_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJlZDM5NDRlNC05NGFiLTRmMzctYjhlNi1iMDJkMjU1NjcyM2QiLCJpZCI6MzAwNDYsInNjb3BlcyI6WyJhc3IiLCJnYyJdLCJpYXQiOjE1OTMwMjMyNDh9.8YbUgVxncQzVXRtrzz2sXbmKUJDKeqjfPPReWmM587s" app.config["cesium_token"] = cesium_token if debug: logging.getLogger("werkzeug").setLevel(logging.DEBUG) logging.getLogger("gdal").setLevel(logging.DEBUG) - logging.getLogger("large_image").setLevel(logging.DEBUG) - logging.getLogger("large_image_source_gdal").setLevel(logging.DEBUG) - logging.getLogger("large_image_source_rasterio").setLevel(logging.DEBUG) + logging.getLogger("rasterio").setLevel(logging.DEBUG) + logging.getLogger("rio_tiler").setLevel(logging.DEBUG) return app diff --git a/localtileserver/web/blueprint.py b/localtileserver/web/blueprint.py index 9bdae439..14cc5951 100644 --- a/localtileserver/web/blueprint.py +++ b/localtileserver/web/blueprint.py @@ -1,8 +1,6 @@ from flask import Blueprint from flask_caching import Cache -from localtileserver.tiler import get_memcache_config - tileserver = Blueprint( "tileserver", __name__, @@ -12,7 +10,7 @@ ) -def create_cache(url: str, username: str = None, password: str = None): +def create_cache(url: str = None, username: str = None, password: str = None): if url: config = {"CACHE_MEMCACHED_SERVERS": url.split(",")} if username and password: @@ -27,4 +25,4 @@ def create_cache(url: str, username: str = None, password: str = None): return Cache(config=config) -cache = create_cache(*get_memcache_config()) +cache = create_cache() diff --git a/localtileserver/web/rest.py b/localtileserver/web/rest.py index 9403e338..b52cfc17 100644 --- a/localtileserver/web/rest.py +++ b/localtileserver/web/rest.py @@ -1,35 +1,26 @@ import io -import json -import pathlib -import re -import time -from urllib.parse import unquote -from PIL import Image, ImageDraw, ImageOps from flask import request, send_file from flask_restx import Api, Resource as View -import large_image -from large_image.exceptions import ( - TileSourceError, - TileSourceInefficientError, - TileSourceXYZRangeError, -) -from werkzeug.exceptions import BadRequest, UnsupportedMediaType +from rasterio import RasterioIOError +from rio_tiler.errors import TileOutsideBounds +from werkzeug.exceptions import BadRequest, NotFound, UnsupportedMediaType from localtileserver import __version__ from localtileserver.tiler import ( format_to_encoding, get_meta_data, - get_region_pixel, - get_region_world, - get_tile_bounds, - get_tile_source, + get_preview, + get_reader, + get_source_bounds, + get_tile, ) -from localtileserver.tiler.data import str_to_bool from localtileserver.tiler.palettes import get_palettes -from localtileserver.tiler.style import make_style, reformat_style_query_parameters from localtileserver.web.blueprint import cache, tileserver -from localtileserver.web.utils import get_clean_filename_from_request +from localtileserver.web.utils import ( + get_clean_filename_from_request, + reformat_list_query_parameters, +) REQUEST_CACHE_TIMEOUT = 60 * 60 * 2 @@ -51,94 +42,32 @@ "type": "str", "example": "https://data.kitware.com/api/v1/file/60747d792fa25629b9a79565/download", }, - "projection": { - "description": "The projection in which to open the image.", - "in": "query", - "type": "str", - "default": "EPSG:3857", - }, } STYLE_PARAMS = { - "band": { - "description": "The band number to use.", + "indexes": { + "description": "The band number(s) to use.", "in": "query", - "type": "int", + "type": "int", # TODO: make this a list }, - "palette": { - "description": "The color palette to map the band values (named Matplotlib colormaps or palettable palettes). `cmap` is a supported alias.", + "colormap": { + "description": "The color palette to map the band values (named Matplotlib colormaps). `cmap` is a supported alias.", "in": "query", "type": "str", }, - "scheme": { - "description": "This is either ``linear`` (the default) or ``discrete``. If a palette is specified, ``linear`` uses a piecewise linear interpolation, and ``discrete`` uses exact colors from the palette with the range of the data mapped into the specified number of colors (e.g., a palette with two colors will split exactly halfway between the min and max values).", - "in": "query", - "type": "str", - "default": "linear", - }, - "n_colors": { - "description": "The number (positive integer) of colors to discretize the matplotlib color palettes when used.", - "in": "query", - "type": "int", - "example": 24, - "default": 255, - }, - "min": { + "vmin": { "description": "The minimum value for the color mapping.", "in": "query", "type": "float", }, - "max": { + "vmax": { "description": "The maximum value for the color mapping.", "in": "query", "type": "float", }, "nodata": { - "description": "The value to map as no data (often made transparent).", - "in": "query", - "type": "float", - }, - "style": { - "description": "Encoded JSON style following https://girder.github.io/large_image/tilesource_options.html#style", - "in": "query", - "type": "str", - }, -} -REGION_PARAMS = { - "left": { - "description": "The left bound (X).", - "in": "query", - "type": "float", - "required": True, - }, - "right": { - "description": "The right bound (X).", - "in": "query", - "type": "float", - "required": True, - }, - "bottom": { - "description": "The bottom bound (Y).", - "in": "query", - "type": "float", - "required": True, - }, - "top": { - "description": "The top bound (Y).", + "description": "The value to map as no data (often made transparent). Defaults to NaN.", "in": "query", "type": "float", - "required": True, - }, - "units": { - "description": "The projection/units of the coordinates.", - "in": "query", - "type": "str", - "default": "EPSG:4326", - }, - "encoding": { - "description": "The encoding of the output image.", - "in": "query", - "type": "str", - "default": "TILED", }, } @@ -155,103 +84,50 @@ def get(self): return get_palettes() -class ListTileSources(View): - def get(self): - large_image.tilesource.loadTileSources() - sources = large_image.tilesource.AvailableTileSources - return {k: str(v) for k, v in sources.items()} - - @api.doc(params=BASE_PARAMS) class BaseImageView(View): - def get_tile_source(self, projection=None): + def get_reader(self): """Return the built tile source.""" try: filename = get_clean_filename_from_request() except OSError as e: - raise BadRequest(str(e)) - projection = request.args.get("projection", projection) - if isinstance(projection, str) and projection.lower() in [ - "none", - "pixel", - "pixels", - "null", - "undefined", - ]: - projection = None - encoding = request.args.get("encoding", "PNG") - if "style" in request.args: - sty = unquote(request.args.get("style")) - try: - # Check that style is valid JSON before passing to large-image - _ = json.loads(sty) - except json.JSONDecodeError as e: - raise BadRequest( - f"`style` query parameter is malformed and likely not properly URL encoded: {e}" - ) - # else, fallback to supported query parameters for viewing a single band - else: - style_args = reformat_style_query_parameters(request.args) - band = style_args.get("band", 0) - if isinstance(band, str) and len(band) > 1: - try: - band = int(band) - except ValueError: - if re.match(r"^\d+(,\d+)*$", band.strip("[]")): - band = [int(b) for b in band.strip("[]").split(",")] - else: - raise BadRequest("`band` query parameter has invalid band values") - vmin = style_args.get("min", None) - vmax = style_args.get("max", None) - palette = style_args.get("palette", style_args.get("cmap", None)) - scheme = style_args.get("scheme", None) - nodata = style_args.get("nodata", None) - if style_args.get("n_colors", ""): - n_colors = int(style_args.get("n_colors")) - else: - n_colors = 255 - try: - sty = make_style( - band, - vmin=vmin, - vmax=vmax, - palette=palette, - nodata=nodata, - scheme=scheme, - n_colors=n_colors, - ) - except ValueError as e: - raise BadRequest(str(e)) + raise BadRequest(str(e)) from e try: - return get_tile_source(filename, projection, encoding=encoding, style=sty) - except TileSourceError as e: - raise BadRequest(f"TileSourceError: {str(e)}") + return get_reader(filename) + except RasterioIOError as e: + raise BadRequest(f"RasterioIOError: {str(e)}") from e + + def get_clean_args(self): + return { + k: v + for k, v in reformat_list_query_parameters(request.args).items() + if k in STYLE_PARAMS + } class ValidateCOGView(BaseImageView): def get(self): from localtileserver.validate import validate_cog - tile_source = self.get_tile_source() - try: - validate_cog(tile_source) - except TileSourceInefficientError as e: - raise UnsupportedMediaType(f"Not a valid Cloud Optimized GeoTiff: {str(e)}") - return "Valid Cloud Optimized GeoTiff" + tile_source = self.get_reader() + valid = validate_cog(tile_source, strict=True) + if not valid: + raise UnsupportedMediaType("Not a valid Cloud Optimized GeoTiff.") + return "Valid Cloud Optimized GeoTiff." class MetadataView(BaseImageView): @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) def get(self): - tile_source = self.get_tile_source() - meta = get_meta_data(tile_source) - meta["filename"] = tile_source.largeImagePath - return meta + tile_source = self.get_reader() + metadata = get_meta_data(tile_source) + metadata["filename"] = str(get_clean_filename_from_request()) + return metadata @api.doc( params={ - "units": { + "crs": { "description": "The projection of the bounds.", "in": "query", "type": "str", @@ -261,14 +137,12 @@ def get(self): ) class BoundsView(BaseImageView): def get(self): - tile_source = self.get_tile_source() - # Override default projection for bounds - units = request.args.get("units", "EPSG:4326") - bounds = get_tile_bounds( + tile_source = self.get_reader() + bounds = get_source_bounds( tile_source, - projection=units, + projection=request.args.get("crs", "EPSG:4326"), ) - bounds["filename"] = tile_source.largeImagePath + bounds["filename"] = str(get_clean_filename_from_request()) return bounds @@ -278,222 +152,32 @@ class ThumbnailView(BaseImageView): def get(self, format: str = "png"): try: encoding = format_to_encoding(format) - except ValueError: - raise BadRequest(f"Format {format} is not a valid encoding.") - tile_source = self.get_tile_source() - thumb_data, mime_type = tile_source.getThumbnail(encoding=encoding) - if isinstance(thumb_data, bytes): - thumb_data = io.BytesIO(thumb_data) - elif isinstance(thumb_data, (str, pathlib.Path)): - with open(thumb_data, "rb") as f: - thumb_data = io.BytesIO(f.read()) + except ValueError as e: + raise BadRequest(f"Format {format} is not a valid encoding.") from e + tile_source = self.get_reader() + thumb_data = get_preview(tile_source, img_format=encoding, **self.get_clean_args()) + thumb_data = io.BytesIO(thumb_data) return send_file( thumb_data, download_name=f"thumbnail.{format}", - mimetype=mime_type, + mimetype="image/png", # TODO ) @api.doc(params=STYLE_PARAMS) -class BaseTileView(BaseImageView): - @staticmethod - def add_border_to_image(content, msg: str = None): - img = Image.open(io.BytesIO(content)) - img = ImageOps.crop(img, 1) - border = ImageOps.expand(img, border=1, fill="black") - if msg is not None: - draw = ImageDraw.Draw(border) - w = draw.textlength(msg, direction="rtl") - h = draw.textlength(msg, direction="ttb") - draw.text(((255 - w) / 2, (255 - h) / 2), msg, fill="red") - img_bytes = io.BytesIO() - border.save(img_bytes, format="PNG") - return img_bytes.getvalue() - - -@api.doc( - params={ - "sleep": { - "description": "The time in seconds to delay serving each tile (useful when debugging to slow things down).", - "in": "query", - "type": "float", - "default": 0.5, - } - } -) -class TileDebugView(View): - """A dummy tile server endpoint that produces borders of the tile grid. - - This is used for testing tile viewers. It returns the same thing on every - call. This takes a query parameter `sleep` to delay the response for - testing (default is 0.5). - - """ - - def get(self, x: int, y: int, z: int): - img = Image.new("RGBA", (254, 254)) - img = ImageOps.expand(img, border=1, fill="black") - draw = ImageDraw.Draw(img) - msg = f"{x}/{y}/{z}" - w = draw.textlength(msg, direction="rtl") - h = draw.textlength(msg, direction="ttb") - draw.text(((255 - w) / 2, (255 - h) / 2), msg, fill="black") - img_bytes = io.BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - time.sleep(float(request.args.get("sleep", 0.5))) - return send_file( - img_bytes, - download_name=f"{x}.{y}.{z}.png", - mimetype="image/png", - ) - - -@api.doc( - params={ - "grid": { - "description": "Show a grid/outline around each tile. This is useful for debugging viewers.", - "in": "query", - "type": "bool", - "default": False, - } - } -) -class TileView(BaseTileView): - @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) - def get(self, x: int, y: int, z: int): - tile_source = self.get_tile_source() +class TileView(BaseImageView): + # @cache.cached(timeout=REQUEST_CACHE_TIMEOUT, key_prefix=make_cache_key) + def get(self, x: int, y: int, z: int, format: str = "png"): + tile_source = self.get_reader() + img_format = format_to_encoding(format) try: - tile_binary = tile_source.getTile(x, y, z) - except TileSourceXYZRangeError as e: - raise BadRequest(str(e)) - mime_type = tile_source.getTileMimeType() - grid = str_to_bool(request.args.get("grid", "False")) - if grid: - tile_binary = self.add_border_to_image(tile_binary, msg=f"{x}/{y}/{z}") + tile_binary = get_tile( + tile_source, z, x, y, img_format=img_format, **self.get_clean_args() + ) + except TileOutsideBounds as e: + raise NotFound(str(e)) from e return send_file( io.BytesIO(tile_binary), download_name=f"{x}.{y}.{z}.png", - mimetype=mime_type, - ) - - -@api.doc(params=REGION_PARAMS) -class BaseRegionView(BaseImageView): - def get_bounds(self): - left = float(request.args.get("left")) - right = float(request.args.get("right")) - bottom = float(request.args.get("bottom")) - top = float(request.args.get("top")) - return (left, right, bottom, top) - - -class RegionWorldView(BaseRegionView): - """Returns region tile binary from world coordinates in given EPSG. - - Use the `units` query parameter to indicate the projection of the given - coordinates. This can be different than the `projection` parameter used - to open the tile source. `units` defaults to `EPSG:4326`. - - """ - - def get(self): - tile_source = self.get_tile_source(projection="EPSG:3857") - units = request.args.get("units", "EPSG:4326") - encoding = request.args.get("encoding", "TILED") - left, right, bottom, top = self.get_bounds() - path, mime_type = get_region_world( - tile_source, - left, - right, - bottom, - top, - units, - encoding, + mimetype=f"image/{img_format}", ) - if not path: - raise BadRequest( - "No output generated, check that the bounds of your ROI overlap source imagery and that your `projection` and `units` are correct." - ) - return send_file( - path, - mimetype=mime_type, - ) - - -class RegionPixelView(BaseRegionView): - """Returns region tile binary from pixel coordinates.""" - - def get(self): - tile_source = self.get_tile_source() - encoding = request.args.get("encoding", None) - left, right, bottom, top = self.get_bounds() - path, mime_type = get_region_pixel( - tile_source, - left, - right, - bottom, - top, - encoding=encoding, - ) - if not path: - raise BadRequest( - "No output generated, check that the bounds of your ROI overlap source imagery." - ) - return send_file( - path, - mimetype=mime_type, - ) - - -@api.doc( - params={ - "projection": { - "description": "The projection in which to open the image (default None).", - "in": "query", - "type": "str", - "default": None, - }, - } -) -class BasePixelOperation(BaseImageView): - pass - - -@api.doc( - params={ - "x": { - "description": "X coordinate (from left of image if in pixel space).", - "in": "query", - "type": "float", - "required": True, - }, - "y": { - "description": "Y coordinate (from top of image if in pixel space).", - "in": "query", - "type": "float", - "required": True, - }, - "units": { - "description": "The projection/units of the coordinates.", - "in": "query", - "type": "str", - "default": "pixels", - "example": "EPSG:4326", - }, - } -) -class PixelView(BasePixelOperation): - """Returns single pixel.""" - - def get(self): - projection = request.args.get("projection", None) - x = float(request.args.get("x")) - y = float(request.args.get("y")) - units = request.args.get("units", "pixels") - tile_source = self.get_tile_source(projection=projection) - region = {"left": x, "top": y, "units": units} - pixel = tile_source.getPixel(region=region) - pixel.pop("value", None) - pixel.update(region) - return pixel diff --git a/localtileserver/web/static/js/cesium.js b/localtileserver/web/static/js/cesium.js index effc421b..8bed8c59 100644 --- a/localtileserver/web/static/js/cesium.js +++ b/localtileserver/web/static/js/cesium.js @@ -13,20 +13,20 @@ let CartoAttribution = 'Map tiles by Carto, unde // - these can be used without Cesium Ion var imageryViewModels = []; -imageryViewModels.push(new Cesium.ProviderViewModel({ - name: 'OpenStreetMap', - iconUrl: Cesium.buildModuleUrl('Widgets/Images/ImageryProviders/openStreetMap.png'), - tooltip: 'OpenStreetMap (OSM) is a collaborative project to create a free editable \ -map of the world.\nhttp://www.openstreetmap.org', - creationFunction: function() { - return new Cesium.UrlTemplateImageryProvider({ - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - subdomains: 'abc', - minimumLevel: 0, - maximumLevel: 19 - }); - } -})); +// imageryViewModels.push(new Cesium.ProviderViewModel({ +// name: 'OpenStreetMap', +// iconUrl: Cesium.buildModuleUrl('Widgets/Images/ImageryProviders/openStreetMap.png'), +// tooltip: 'OpenStreetMap (OSM) is a collaborative project to create a free editable \ +// map of the world.\nhttp://www.openstreetmap.org', +// creationFunction: function() { +// return new Cesium.UrlTemplateImageryProvider({ +// url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', +// subdomains: 'abc', +// minimumLevel: 0, +// maximumLevel: 19 +// }); +// } +// })); imageryViewModels.push(new Cesium.ProviderViewModel({ name: 'Positron', tooltip: 'CartoDB Positron basemap', @@ -117,63 +117,11 @@ imageryViewModels.push(new Cesium.ProviderViewModel({ }); } })); -imageryViewModels.push(new Cesium.ProviderViewModel({ - name: 'Stamen Terrain', - iconUrl: 'https://stamen-tiles-a.a.ssl.fastly.net/terrain/5/15/12.png', - creationFunction: function() { - return new Cesium.UrlTemplateImageryProvider({ - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/terrain/{z}/{x}/{y}.png', - credit: StamenAttribution, - subdomains: 'abcd', - minimumLevel: 0, - maximumLevel: 14 - }); - } -})); -imageryViewModels.push(new Cesium.ProviderViewModel({ - name: 'Stamen Terrain Background', - iconUrl: 'https://stamen-tiles-a.a.ssl.fastly.net/terrain-background/5/15/12.png', - creationFunction: function() { - return new Cesium.UrlTemplateImageryProvider({ - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}.png', - credit: StamenAttribution, - subdomains: 'abcd', - minimumLevel: 0, - maximumLevel: 14 - }); - } -})); -imageryViewModels.push(new Cesium.ProviderViewModel({ - name: 'Stamen Toner', - iconUrl: 'https://stamen-tiles-a.a.ssl.fastly.net/toner/5/15/12.png', - creationFunction: function() { - return new Cesium.UrlTemplateImageryProvider({ - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png', - credit: StamenAttribution, - subdomains: 'abcd', - minimumLevel: 0, - maximumLevel: 14 - }); - } -})); -imageryViewModels.push(new Cesium.ProviderViewModel({ - name: 'Stamen Toner Lite', - iconUrl: 'https://stamen-tiles-a.a.ssl.fastly.net/toner-lite/5/15/12.png', - creationFunction: function() { - return new Cesium.UrlTemplateImageryProvider({ - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png', - credit: StamenAttribution, - subdomains: 'abcd', - minimumLevel: 0, - maximumLevel: 14 - }); - } -})); // Initialize the viewer - this works without a token! viewer = new Cesium.Viewer('cesiumContainer', { imageryProviderViewModels: imageryViewModels, - selectedImageryProviderViewModel: imageryViewModels[8], // Terrain + selectedImageryProviderViewModel: imageryViewModels[0], animation: false, timeline: false, infoBox: false, diff --git a/localtileserver/web/static/js/geojs.js b/localtileserver/web/static/js/geojs.js deleted file mode 100644 index dd7e3225..00000000 --- a/localtileserver/web/static/js/geojs.js +++ /dev/null @@ -1,74 +0,0 @@ -// Initialize the map -let map = geo.map({ - node: '#map', - clampBoundsX: true -}) - -///////////////////////////// -// Map Layers - order matters - -// Basemap -var basemapLayer = map.createLayer('osm', { - source: getCookie('basemapChoice', 'osm'), - gcs: 'EPSG:3857' // web mercator -}); - -// Tile layer for showing rasters/images with large_image -var tileLayer = map.createLayer('osm', { - keepLower: false, - attribution: '', - autoshareRenderer: false, -}); -tileLayer.visible(false) - -// Feature/data layer -let layer = map.createLayer('feature', { - features: ['polygon', 'marker'] -}); -let reader = geo.createFileReader('geojsonReader', { - 'layer': layer -}); - -// User Interface layer -var ui = map.createLayer('ui'); - -// Annotation layer -var annotationLayer = map.createLayer('annotation', { - clickToEdit: true, - showLabels: false -}); - -///////////////////////////// - -// Increase zoom range from default of 16 -map.zoomRange({ - min: 0, - max: 20, -}) - -// Position the map to show data extents. If none present, the position -// should have been set by the search parameters -function setBounds(extent, setMax = false) { - if (extent != undefined && extent.xmin != undefined) { - let xc = (extent.xmax - extent.xmin) * 0.2 - let yc = (extent.ymax - extent.ymin) * 0.2 - if (xc === 0) { - xc = 0.01 - } - if (yc === 0) { - yc = 0.01 - } - var bounds = { - left: Math.max(extent.xmin - xc, -180.0), - right: Math.min(extent.xmax + xc, 180.0), - top: Math.min(extent.ymax + yc, 89.9999), - bottom: Math.max(extent.ymin - yc, -89.9999) - } - map.bounds(bounds); - if (setMax) { - map.maxBounds(bounds) - } else { - map.zoom(map.zoom() - 0.25); - } - } -} diff --git a/localtileserver/web/static/js/geojsControls.js b/localtileserver/web/static/js/geojsControls.js deleted file mode 100644 index 9ebf68e6..00000000 --- a/localtileserver/web/static/js/geojsControls.js +++ /dev/null @@ -1,96 +0,0 @@ -// Fill select drop down -var options = geo.osmLayer.tileSources; -for (const option in options) { - var newOption = document.createElement('option'); - newOption.value = option; - newOption.text = options[option].name ? options[option].name : option; - document.getElementById('basemapDropdown').appendChild(newOption) -} - -var basemapDropdown = document.getElementById("basemapDropdown") -basemapDropdown.value = basemapLayer.source() - -function changeBasemap() { - if (basemapDropdown.value == '-- none --') { - basemapLayer.visible(false) - } else { - basemapLayer.visible(true) - setCookie('basemapChoice', basemapDropdown.value) - basemapLayer.source(basemapDropdown.value) - } -} - - -var b1 = document.getElementById("drawROIButton") -var b2 = document.getElementById("extractROIButton") -var b3 = document.getElementById("clearROIButton") -var roi; - -// Add callback to annotation layer -annotationLayer.geoOn(geo.event.annotation.state, (e) => { - if (e.annotation.state() === "edit") { - // Prevent downloading while editing as last complete state is used and - // could yield seemingly wrong results - b2.disabled = true - } else if (e.annotation.state() === "done") { - var coords = e.annotation.coordinates(); - // Re-enable button - b1.disabled = false - b2.disabled = false - // Get the bounding box - roi = { - left: coords[0].x, - right: coords[1].x, - bottom: coords[1].y, - top: coords[2].y - }; - var xx = coords.forEach((p) => { - if (p.x < roi.left) { - roi.left = p.x - } - if (p.x > roi.right) { - roi.right = p.x - } - if (p.y < roi.bottom) { - roi.bottom = p.y - } - if (p.y > roi.top) { - roi.top = p.y - } - }); - } -}); - -function enableDrawROI() { - // Disable button until completed - b1.disabled = true - b2.disabled = true - // Make sure clear button is enabled - b3.disabled = false - // Clear any previous annotations - annotationLayer.removeAllAnnotations() - // Start new annotation - annotationLayer.mode('rectangle'); -} - -function downloadROI() { - // Check if ROI is outside of the bounds of raster as this will - // yield invalid results - if (roi.left < extents.xmin | - roi.right > extents.xmax | - roi.top > extents.ymax | - roi.bottom < extents.ymin) { - snackbarError('ROI exceeds the boundary of the source raster.'); - return - } - // Build a URL for extracting that ROI - var url = `${host}/api/world/region.tif?units=EPSG:4326&left=${roi.left}&right=${roi.right}&bottom=${roi.bottom}&top=${roi.top}&filename=${filename}`; - download(url, 'region.tif') -} - -function clearROI() { - annotationLayer.removeAllAnnotations() - b1.disabled = false - b2.disabled = true - b3.disabled = true -} diff --git a/localtileserver/web/static/styles/geojs.css b/localtileserver/web/static/styles/geojs.css deleted file mode 100644 index 14db6fe7..00000000 --- a/localtileserver/web/static/styles/geojs.css +++ /dev/null @@ -1,33 +0,0 @@ -#map { - width: 100%; - height: 100%; - z-index: 10; - height: calc(100vh - 180px); -} - -.btn-group .button:hover { - background-color: #3e8e41; -} - -.geojsControls { - width: 100%; - margin: 0; - padding: 0; - position: absolute; -} - -.basemapTool { - background-color: #DCDCDC; - padding: 2px; - border: 2px solid #000; - text-align: left; - border-radius: 10px; -} - -.slidecontainer { - background-color: #DCDCDC; - padding: 2px; - border: 2px solid #000; - text-align: left; - border-radius: 10px; -} diff --git a/localtileserver/web/templates/tileserver/_include/examples.html b/localtileserver/web/templates/tileserver/_include/examples.html index 2bf82d59..3db45854 100644 --- a/localtileserver/web/templates/tileserver/_include/examples.html +++ b/localtileserver/web/templates/tileserver/_include/examples.html @@ -5,11 +5,11 @@ - + - + diff --git a/localtileserver/web/templates/tileserver/_include/geojs.html b/localtileserver/web/templates/tileserver/_include/geojs.html deleted file mode 100644 index 8bd6d62c..00000000 --- a/localtileserver/web/templates/tileserver/_include/geojs.html +++ /dev/null @@ -1,10 +0,0 @@ -{% block empty_viewer %} - - - - - -
- - -{% endblock %} diff --git a/localtileserver/web/templates/tileserver/_include/geojsControls.html b/localtileserver/web/templates/tileserver/_include/geojsControls.html deleted file mode 100644 index e1e8302a..00000000 --- a/localtileserver/web/templates/tileserver/_include/geojsControls.html +++ /dev/null @@ -1,29 +0,0 @@ -{% block viewer_controls %} - -
-
- - -
-
- - -
-
-
- -
-
- -
-
- -
-
-
- - - -{% endblock %} diff --git a/localtileserver/web/templates/tileserver/base.html b/localtileserver/web/templates/tileserver/base.html index 3d139d72..4683c235 100644 --- a/localtileserver/web/templates/tileserver/base.html +++ b/localtileserver/web/templates/tileserver/base.html @@ -107,14 +107,11 @@
Tile Server