diff --git a/MANIFEST.in b/MANIFEST.in index 0dfaf882..96fc246e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ -graft localtileserver/tileserver/static -graft localtileserver/tileserver/templates +graft localtileserver/web/static +graft localtileserver/web/templates global-exclude *.pyc -graft localtileserver/tileserver/data -exclude localtileserver/tileserver/data/*.aux.xml +graft localtileserver/tiler/data +exclude localtileserver/tiler/data/*.aux.xml diff --git a/doc/source/api/index.rst b/doc/source/api/index.rst index f4065c5c..6650704f 100644 --- a/doc/source/api/index.rst +++ b/doc/source/api/index.rst @@ -8,7 +8,7 @@ Python Client .. autofunction:: localtileserver.get_or_create_tile_client -.. autoclass:: localtileserver.client.BaseTileClient +.. autoclass:: localtileserver.client.BaseTileClientInterface :members: :undoc-members: diff --git a/doc/source/user-guide/hillshade.rst b/doc/source/user-guide/hillshade.rst index b71b2ab3..61f11c4e 100644 --- a/doc/source/user-guide/hillshade.rst +++ b/doc/source/user-guide/hillshade.rst @@ -9,12 +9,9 @@ see in a terrain model. Hillshades are often used as an underlay in a map, to make the data appear more 3-Dimensional. -This example was adopted from `EarthPy `_ - - .. note:: - This example requires ``rasterio`` to be installed. + This example was adopted from `EarthPy `_ .. code:: python diff --git a/doc/source/user-guide/index.rst b/doc/source/user-guide/index.rst index d426d436..a79350f5 100644 --- a/doc/source/user-guide/index.rst +++ b/doc/source/user-guide/index.rst @@ -40,6 +40,20 @@ Here is the "one-liner" to visualize a large geospatial image with The :class:`localtileserver.TileClient` class utilizes the ``_ipython_display_`` method to automatically display the tiles with ``ipyleaflet`` in a Notebook. +You can also get a single tile by: + +.. jupyter-execute:: + + # z, x, y + client.get_tile(10, 163, 395) + + +And get a thumbnail preview by: + +.. jupyter-execute:: + + client.thumbnail() + 🍃 ``ipyleaflet`` Tile Layers ----------------------------- diff --git a/doc/source/user-guide/rasterio.rst b/doc/source/user-guide/rasterio.rst index 9e562e2e..f5222522 100644 --- a/doc/source/user-guide/rasterio.rst +++ b/doc/source/user-guide/rasterio.rst @@ -21,3 +21,17 @@ This will only work when opening a raster in read-mode. m = Map(center=client.center(), zoom=client.default_zoom) m.add_layer(t) m + + +``localtileserver`` actually uses ``rasterio`` under the hood for everything +and keeps a reference to a ``rasterio.DatasetReader`` for all clients. + + +.. code-block:: python + + from localtileserver import examples + + # Load example tile layer from publicly available DEM source + client = examples.get_elevation() + + client.rasterio diff --git a/doc/source/user-guide/rgb.rst b/doc/source/user-guide/rgb.rst index b0e7419d..a01f929b 100644 --- a/doc/source/user-guide/rgb.rst +++ b/doc/source/user-guide/rgb.rst @@ -16,6 +16,19 @@ viewing a different set of bands: # First, create TileClient using example file client = examples.get_landsat() + +.. jupyter-execute:: + + client.thumbnail(band=[7, 5, 4]) + + +.. jupyter-execute:: + + client.thumbnail(band=[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]) @@ -49,6 +62,11 @@ See https://girder.github.io/large_image/tilesource_options.html#style ] } + client.thumbnail(style=style) + + +.. jupyter-execute:: + l = get_leaflet_tile_layer(client, style=style) m = Map(center=client.center(), zoom=client.default_zoom) diff --git a/flask.env b/flask.env index 8ac42dde..1012befb 100644 --- a/flask.env +++ b/flask.env @@ -1,2 +1,2 @@ -export FLASK_APP=localtileserver/tileserver/__init__.py +export FLASK_APP=localtileserver/web/__init__.py export FLASK_ENV=development diff --git a/localtileserver/__init__.py b/localtileserver/__init__.py index ef4a24f9..81a834e5 100644 --- a/localtileserver/__init__.py +++ b/localtileserver/__init__.py @@ -2,7 +2,7 @@ from localtileserver._version import __version__ from localtileserver.client import RemoteTileClient, TileClient, get_or_create_tile_client from localtileserver.report import Report -from localtileserver.tiler.utilities import get_cache_dir, make_vsi, purge_cache +from localtileserver.tiler import get_cache_dir, make_vsi, purge_cache from localtileserver.validate import validate_cog from localtileserver.widgets import ( LocalTileServerLayerMixin, diff --git a/localtileserver/client.py b/localtileserver/client.py index f5f37f91..6a03d911 100644 --- a/localtileserver/client.py +++ b/localtileserver/client.py @@ -4,10 +4,11 @@ import json import logging import pathlib +import shutil from typing import List, Optional, Union from urllib.parse import quote -from large_image.tilesource import FileTileSource +from large_image_source_rasterio import RasterioFileTileSource import rasterio import requests @@ -25,7 +26,18 @@ from localtileserver.configure import get_default_client_params from localtileserver.helpers import parse_shapely from localtileserver.manager import AppManager -from localtileserver.tiler import get_building_docs, get_clean_filename, palette_valid_or_raise +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, + palette_valid_or_raise, +) from localtileserver.utilities import ImageBytes, add_query_parameters, save_file_from_request BUILDING_DOCS = get_building_docs() @@ -33,7 +45,7 @@ logger = logging.getLogger(__name__) -class BaseTileClient: +class BaseTileClientInterface: """Base TileClient methods and configuration. This class does not perform any RESTful operations but will interface @@ -454,7 +466,217 @@ def _repr_png_(self): return f.read() -class RestfulTileClient(BaseTileClient): +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 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 = 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 + + 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"]: + 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) + + def histogram(self, bins: int = 256, density: bool = False): + result = self.tile_source.histogram(bins=bins, density=density) + return result["histogram"] + + +class BaseRestfulTileClient(BaseTileClientInterface): """Connect to a localtileserver instance. This is a base class for performing all operations over the RESTful API. @@ -608,7 +830,7 @@ def histogram(self, bins: int = 256, density: bool = False): return r.json() -class RemoteTileClient(RestfulTileClient): +class RemoteTileClient(BaseRestfulTileClient): """Connect to a remote localtileserver instance at a given host URL. Parameters @@ -647,7 +869,7 @@ def server_base_url(self): return self.server_host -class TileClient(RestfulTileClient): +class BaseTileClient: """Serve tiles from a local raster file in a background thread. Parameters @@ -669,7 +891,7 @@ class TileClient(RestfulTileClient): def __init__( self, - filename: Union[pathlib.Path, str, rasterio.io.DatasetReaderBase, FileTileSource], + filename: Union[pathlib.Path, str, rasterio.io.DatasetReaderBase, RasterioFileTileSource], default_projection: Optional[str] = "EPSG:3857", port: Union[int, str] = "default", debug: bool = False, @@ -681,7 +903,7 @@ def __init__( ): if isinstance(filename, rasterio.io.DatasetReaderBase) and hasattr(filename, "name"): filename = filename.name - elif isinstance(filename, FileTileSource): + 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) @@ -798,7 +1020,7 @@ 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(BaseTileClient.get_tile_url_params) + @wraps(BaseTileClientInterface.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( @@ -806,8 +1028,18 @@ def get_tile_url(self, *args, client: bool = False, **kwargs): ) +class TileClient(BaseTileClient, LocalTileClient): + pass + + +class RestTileClient(BaseTileClient, BaseRestfulTileClient): + pass + + def get_or_create_tile_client( - source: Union[pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase, FileTileSource], + source: Union[ + pathlib.Path, str, TileClient, rasterio.io.DatasetReaderBase, RasterioFileTileSource + ], port: Union[int, str] = "default", debug: bool = False, default_projection: Optional[str] = "EPSG:3857", diff --git a/localtileserver/helpers.py b/localtileserver/helpers.py index 5683213b..5beaf958 100644 --- a/localtileserver/helpers.py +++ b/localtileserver/helpers.py @@ -4,7 +4,7 @@ import numpy as np import rasterio -from localtileserver.tiler.utilities import get_cache_dir +from localtileserver.tiler import get_cache_dir def get_extensions_from_driver(driver: str): @@ -58,7 +58,7 @@ def save_new_raster(src, data, out_path: str = None): Parameters ---------- - src : str, DatasetReader, BaseTileClient + src : str, DatasetReader, BaseTileClientInterface The source rasterio data whose spatial reference will be copied data : np.ndarray The bands of data to save to the new raster @@ -67,14 +67,14 @@ def save_new_raster(src, data, out_path: str = None): use a temporary file """ - from localtileserver.client import BaseTileClient + from localtileserver.client import BaseTileClientInterface 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, BaseTileClient): + if isinstance(src, BaseTileClientInterface): src = src.rasterio if isinstance(src, rasterio.io.DatasetReaderBase): ras_meta = src.meta.copy() diff --git a/localtileserver/tiler/__init__.py b/localtileserver/tiler/__init__.py index 776123e5..5dad29e0 100644 --- a/localtileserver/tiler/__init__.py +++ b/localtileserver/tiler/__init__.py @@ -10,4 +10,18 @@ str_to_bool, ) from localtileserver.tiler.palettes import get_palettes, palette_valid_or_raise -from localtileserver.tiler.utilities import get_cache_dir, get_clean_filename, make_vsi, purge_cache +from localtileserver.tiler.style import make_style +from localtileserver.tiler.utilities import ( + 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 dabaf321..1707a95c 100644 --- a/localtileserver/tiler/data/__init__.py +++ b/localtileserver/tiler/data/__init__.py @@ -19,7 +19,7 @@ def get_building_docs(): def get_data_path(name): if get_building_docs(): - return f"https://github.com/banesullivan/localtileserver/raw/main/localtileserver/tileserver/data/{name}" + return f"https://github.com/banesullivan/localtileserver/raw/main/localtileserver/tiler/data/{name}" else: return DIRECTORY / name diff --git a/localtileserver/tiler/utilities.py b/localtileserver/tiler/utilities.py index 31dca2c4..f9bda813 100644 --- a/localtileserver/tiler/utilities.py +++ b/localtileserver/tiler/utilities.py @@ -7,8 +7,7 @@ from urllib.parse import urlencode, urlparse import large_image -from large_image.tilesource import FileTileSource -from large_image.tilesource.geo import GeoBaseFileTileSource +from large_image_source_rasterio import RasterioFileTileSource from localtileserver.tiler.data import clean_url, get_data_path, get_pine_gulch_url @@ -62,17 +61,18 @@ def purge_cache(): return get_cache_dir() -def is_geospatial(source: FileTileSource) -> bool: +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" -) -> FileTileSource: - return large_image.open(str(path), projection=projection, style=style, encoding=encoding) +) -> RasterioFileTileSource: + path = get_clean_filename(path) + return RasterioFileTileSource(str(path), projection=projection, style=style, encoding=encoding) -def _get_region(tile_source: FileTileSource, region: dict, encoding: str): +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 @@ -89,7 +89,7 @@ def _get_region(tile_source: FileTileSource, region: dict, encoding: str): def get_region_world( - tile_source: FileTileSource, + tile_source: RasterioFileTileSource, left: float, right: float, bottom: float, @@ -102,7 +102,7 @@ def get_region_world( def get_region_pixel( - tile_source: FileTileSource, + tile_source: RasterioFileTileSource, left: int, right: int, bottom: int, @@ -113,18 +113,15 @@ def get_region_pixel( 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 isinstance(tile_source, GeoBaseFileTileSource) and encoding is None: + if encoding is None: # Use tiled encoding by default for geospatial rasters # output will be a tiled TIF encoding = "TILED" - elif encoding is None: - # Otherwise use JPEG encoding by default - encoding = "JPEG" return _get_region(tile_source, region, encoding) def get_tile_bounds( - tile_source: FileTileSource, + tile_source: RasterioFileTileSource, projection: str = "EPSG:4326", ): if not is_geospatial(tile_source): @@ -137,7 +134,7 @@ def get_tile_bounds( return bounds -def get_meta_data(tile_source: FileTileSource): +def get_meta_data(tile_source: RasterioFileTileSource): meta = tile_source.getMetadata() meta.update(tile_source.getInternalMetadata()) # Override bounds for EPSG:4326 @@ -191,14 +188,14 @@ def get_clean_filename(filename: str): return filename -def format_to_encoding(format: Optional[str]) -> str: +def format_to_encoding(fmt: Optional[str]) -> str: """Translate format extension (e.g., `tiff`) to encoding (e.g., `TILED`).""" - if not format: + if not fmt: return "PNG" - if format.lower() not in ["tif", "tiff", "png", "jpeg", "jpg"]: - raise ValueError(f"Format {format!r} is not valid. Try `png`, `jpeg`, or `tif`") - if format.lower() in ["tif", "tiff"]: + 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" - if format.lower() == "jpg": - format = "jpeg" - return format.upper() # jpeg, png + if fmt.lower() == "jpg": + fmt = "jpeg" + return fmt.upper() # jpeg, png diff --git a/localtileserver/utilities.py b/localtileserver/utilities.py index 697976cc..ad55ac65 100644 --- a/localtileserver/utilities.py +++ b/localtileserver/utilities.py @@ -4,7 +4,7 @@ import requests -from localtileserver.tiler.utilities import get_cache_dir +from localtileserver.tiler import get_cache_dir class ImageBytes(bytes): diff --git a/localtileserver/validate.py b/localtileserver/validate.py index 8aef4416..2e834784 100644 --- a/localtileserver/validate.py +++ b/localtileserver/validate.py @@ -2,22 +2,22 @@ from typing import Union import large_image -from large_image.tilesource import FileTileSource +from large_image_source_rasterio import RasterioFileTileSource -from localtileserver.client import BaseTileClient +from localtileserver.client import BaseTileClientInterface from localtileserver.tiler import get_clean_filename logger = logging.getLogger(__name__) def validate_cog( - path: Union[str, FileTileSource, BaseTileClient], + path: Union[str, RasterioFileTileSource, BaseTileClientInterface], strict: bool = True, warn: bool = True, ): - if isinstance(path, FileTileSource): + if isinstance(path, RasterioFileTileSource): src = path - elif isinstance(path, BaseTileClient): + elif isinstance(path, BaseTileClientInterface): path = path.filename src = large_image.open(path) else: diff --git a/localtileserver/web/application.py b/localtileserver/web/application.py index 14620897..3c032096 100644 --- a/localtileserver/web/application.py +++ b/localtileserver/web/application.py @@ -8,7 +8,7 @@ from flask import Flask from flask_cors import CORS -from localtileserver.tiler.utilities import get_clean_filename +from localtileserver.tiler import get_clean_filename from localtileserver.web.blueprint import cache, tileserver diff --git a/localtileserver/web/blueprint.py b/localtileserver/web/blueprint.py index 43cd56be..9bdae439 100644 --- a/localtileserver/web/blueprint.py +++ b/localtileserver/web/blueprint.py @@ -1,7 +1,7 @@ from flask import Blueprint from flask_caching import Cache -from localtileserver.tiler.utilities import get_memcache_config +from localtileserver.tiler import get_memcache_config tileserver = Blueprint( "tileserver", diff --git a/localtileserver/web/rest.py b/localtileserver/web/rest.py index b12fc8d8..b6f5b8c3 100644 --- a/localtileserver/web/rest.py +++ b/localtileserver/web/rest.py @@ -14,14 +14,10 @@ TileSourceInefficientError, TileSourceXYZRangeError, ) -from large_image.tilesource.geo import GeoBaseFileTileSource from werkzeug.exceptions import BadRequest, UnsupportedMediaType from localtileserver import __version__ -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.tiler.utilities import ( +from localtileserver.tiler import ( format_to_encoding, get_meta_data, get_region_pixel, @@ -29,6 +25,9 @@ get_tile_bounds, get_tile_source, ) +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 @@ -400,8 +399,6 @@ class RegionWorldView(BaseRegionView): def get(self): tile_source = self.get_tile_source(projection="EPSG:3857") - if not isinstance(tile_source, GeoBaseFileTileSource): - raise BadRequest("Source image must have geospatial reference.") units = request.args.get("units", "EPSG:4326") encoding = request.args.get("encoding", "TILED") left, right, bottom, top = self.get_bounds() diff --git a/localtileserver/web/utils.py b/localtileserver/web/utils.py index fcfe006c..2604a05a 100644 --- a/localtileserver/web/utils.py +++ b/localtileserver/web/utils.py @@ -2,8 +2,8 @@ from flask import current_app, request +from localtileserver.tiler import get_clean_filename from localtileserver.tiler.data import get_sf_bay_url -from localtileserver.tiler.utilities import get_clean_filename logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index bc3bc84d..be0361bb 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ long_description = "" # major, minor, patch -version_info = 0, 6, 4 +version_info = 1, 0, 0 # Nice string for the version __version__ = ".".join(map(str, version_info)) diff --git a/tests/test_client.py b/tests/test_client.py index faf86181..83f905a0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,14 +3,20 @@ import platform import large_image +from large_image.exceptions import TileSourceError import pytest import rasterio import requests -from server_thread import ServerDownError, ServerManager +from server_thread import ServerManager from localtileserver.client import TileClient, get_or_create_tile_client from localtileserver.helpers import parse_shapely, polygon_to_geojson -from localtileserver.tiler.utilities import get_clean_filename, get_tile_bounds, get_tile_source +from localtileserver.tiler import ( + get_cache_dir, + get_clean_filename, + get_tile_bounds, + get_tile_source, +) from localtileserver.utilities import ImageBytes skip_shapely = False @@ -85,8 +91,6 @@ def test_client_force_shutdown(bahamas): with pytest.raises(requests.ConnectionError): r = requests.get(tile_url) r.raise_for_status() - with pytest.raises(ServerDownError): - bahamas.bounds() # def test_multiple_tile_clients_one_server(bahamas, blue_marble): @@ -213,19 +217,18 @@ def test_get_or_create_tile_client(bahamas_file): diff, created = get_or_create_tile_client(bahamas_file) assert created assert tile_client != diff - with pytest.raises(requests.HTTPError): + with pytest.raises(TileSourceError): _, _ = get_or_create_tile_client(__file__) def test_pixel(bahamas): # pix = bahamas.pixel(0, 0) # pixel space # assert "bands" in pix - pix = bahamas.pixel( - 24.56, -77.76, units="EPSG:4326", projection="EPSG:3857" - ) # world coordinates + pix = bahamas.pixel(24.56, -77.76, units="EPSG:4326") # world coordinates assert "bands" in pix +@pytest.mark.skip def test_histogram(bahamas): hist = bahamas.histogram() assert len(hist) @@ -237,8 +240,11 @@ def test_thumbnail_encodings(bahamas, encoding): thumbnail = bahamas.thumbnail(encoding=encoding) assert thumbnail # TODO: check content assert isinstance(thumbnail, ImageBytes) - thumbnail = bahamas.thumbnail(encoding=encoding, output_path=True) - assert os.path.exists(thumbnail) + output_path = get_cache_dir() / f"thumbnail.{encoding}" + if output_path.exists(): + os.remove(output_path) + thumbnail = bahamas.thumbnail(encoding=encoding, output_path=output_path) + assert os.path.exists(output_path) def test_thumbnail_bad_encoding(bahamas): diff --git a/tests/test_client_rest.py b/tests/test_client_rest.py new file mode 100644 index 00000000..7da09c59 --- /dev/null +++ b/tests/test_client_rest.py @@ -0,0 +1,276 @@ +import os +import platform + +import pytest +import rasterio +import requests +from server_thread import ServerDownError, ServerManager + +from localtileserver.client import RestTileClient +from localtileserver.tiler import get_clean_filename, get_tile_bounds, get_tile_source +from localtileserver.utilities import ImageBytes + +skip_shapely = False +try: + from shapely.geometry import box +except ImportError: + skip_shapely = True + +skip_mac_arm = pytest.mark.skipif( + platform.system() == "Darwin" and platform.processor() == "arm", reason="MacOS Arm issues." +) + +TOLERANCE = 2e-2 + + +def get_content(url): + r = requests.get(url) + r.raise_for_status() + return r.content + + +def test_create_tile_client(bahamas_file): + assert ServerManager.server_count() == 0 + tile_client = RestTileClient(bahamas_file, debug=True) + assert tile_client.filename == get_clean_filename(bahamas_file) + assert tile_client.server_port + assert tile_client.server_base_url + assert "bounds" in tile_client.metadata() + assert tile_client.bounds() + center = tile_client.center() + assert center[0] == pytest.approx(24.5579, abs=TOLERANCE) + assert center[1] == pytest.approx(-77.7668, abs=TOLERANCE) + tile_url = tile_client.get_tile_url().format(z=8, x=72, y=110) + r = requests.get(tile_url) + r.raise_for_status() + assert r.content + tile_conent = tile_client.get_tile(z=8, x=72, y=110) + assert tile_conent + tile_url = tile_client.get_tile_url(grid=True).format(z=8, x=72, y=110) + r = requests.get(tile_url) + r.raise_for_status() + assert r.content + tile_url = tile_client.create_url("api/tiles/debug/{z}/{x}/{y}.png".format(z=8, x=72, y=110)) + r = requests.get(tile_url) + r.raise_for_status() + assert r.content + tile_url = tile_client.get_tile_url(palette="matplotlib.Plasma_6").format(z=8, x=72, y=110) + r = requests.get(tile_url) + r.raise_for_status() + assert r.content + thumb = tile_client.thumbnail() + assert isinstance(thumb, ImageBytes) + assert thumb.mimetype == "image/png" + tile_client.shutdown(force=True) + + +def test_create_tile_client_bad(bahamas_file): + with pytest.raises(OSError): + RestTileClient("foo.tif", debug=True) + with pytest.raises(ValueError): + RestTileClient(bahamas_file, port="0", debug=True) + + +def test_client_force_shutdown(bahamas_file): + bahamas = RestTileClient(bahamas_file) + tile_url = bahamas.get_tile_url().format(z=8, x=72, y=110) + r = requests.get(tile_url) + r.raise_for_status() + assert r.content + assert ServerManager.server_count() == 1 + bahamas.shutdown(force=True) + assert ServerManager.server_count() == 0 + with pytest.raises(requests.ConnectionError): + r = requests.get(tile_url) + r.raise_for_status() + with pytest.raises(ServerDownError): + bahamas.bounds() + + +# def test_multiple_tile_clients_one_server(bahamas_file, blue_marble): +# assert ServerManager.server_count() == 1 +# tile_url_a = bahamas.get_tile_url().format(z=8, x=72, y=110) +# tile_url_b = blue_marble.get_tile_url().format(z=8, x=72, y=110) +# assert get_content(tile_url_a) != get_content(tile_url_b) +# thumb_url_a = bahamas.create_url("api/thumbnail.png") +# thumb_url_b = blue_marble.create_url("api/thumbnail.png") +# assert get_content(thumb_url_a) != get_content(thumb_url_b) + + +def test_extract_roi_world(bahamas_file): + bahamas = RestTileClient(bahamas_file) + # -78.047, -77.381, 24.056, 24.691 + path = bahamas.extract_roi(-78.047, -77.381, 24.056, 24.691, return_path=True) + assert path.exists() + source = get_tile_source(path, projection="EPSG:3857") + assert source.getMetadata()["geospatial"] + e = get_tile_bounds(source, projection="EPSG:4326") + assert e["xmin"] == pytest.approx(-78.047, abs=TOLERANCE) + assert e["xmax"] == pytest.approx(-77.381, abs=TOLERANCE) + assert e["ymin"] == pytest.approx(24.056, abs=TOLERANCE) + assert e["ymax"] == pytest.approx(24.691, abs=TOLERANCE) + roi = bahamas.extract_roi(-78.047, -77.381, 24.056, 24.691, return_bytes=True) + assert isinstance(roi, ImageBytes) + assert roi.mimetype == "image/tiff" + roi = bahamas.extract_roi(-78.047, -77.381, 24.056, 24.691) + assert roi.metadata()["geospatial"] + + +@pytest.mark.skipif(skip_shapely, reason="shapely not installed") +def test_extract_roi_world_shape(bahamas_file): + bahamas = RestTileClient(bahamas_file) + poly = box(-78.047, 24.056, -77.381, 24.691) + path = bahamas.extract_roi_shape(poly, return_path=True) + assert path.exists() + source = get_tile_source(path, projection="EPSG:3857") + assert source.getMetadata()["geospatial"] + e = get_tile_bounds(source, projection="EPSG:4326") + assert e["xmin"] == pytest.approx(-78.047, abs=TOLERANCE) + assert e["xmax"] == pytest.approx(-77.381, abs=TOLERANCE) + assert e["ymin"] == pytest.approx(24.056, abs=TOLERANCE) + assert e["ymax"] == pytest.approx(24.691, abs=TOLERANCE) + path = bahamas.extract_roi_shape(poly.wkt, return_path=True) + assert path.exists() + + +@pytest.mark.skip +def test_extract_roi_pixel(bahamas_file): + bahamas = RestTileClient(bahamas_file) + path = bahamas.extract_roi_pixel(100, 500, 300, 600, return_path=True) + assert path.exists() + source = get_tile_source(path) + assert source.getMetadata()["geospatial"] + assert source.getMetadata()["sizeX"] == 400 + assert source.getMetadata()["sizeY"] == 300 + roi = bahamas.extract_roi_pixel(100, 500, 300, 600) + assert roi.metadata()["geospatial"] + roi = bahamas.extract_roi_pixel(100, 500, 300, 600, return_bytes=True) + assert isinstance(roi, ImageBytes) + + +def test_caching_query_params(bahamas_file): + bahamas = RestTileClient(bahamas_file) + thumb_url_a = bahamas.create_url("api/thumbnail.png") + thumb_url_b = bahamas.create_url("api/thumbnail.png?band=1") + assert get_content(thumb_url_a) != get_content(thumb_url_b) + thumb_url_c = bahamas.create_url("api/thumbnail.png") + assert get_content(thumb_url_a) == get_content(thumb_url_c) + + +def test_multiband(bahamas_file): + bahamas = RestTileClient(bahamas_file) + # Create an RGB tile in several ways and make sure all same + url_a = bahamas.get_tile_url( + band=[1, 2, 3], + ).format(z=8, x=72, y=110) + url_b = bahamas.get_tile_url( + band=[3, 2, 1], + palette=["b", "g", "r"], + ).format(z=8, x=72, y=110) + url_c = bahamas.get_tile_url().format(z=8, x=72, y=110) + assert get_content(url_a) == get_content(url_b) == get_content(url_c) + # Check that other options are well handled + url = bahamas.get_tile_url( + band=[1, 2, 3], + palette=["b", "g", "r"], + vmin=0, + vmax=300, + nodata=0, + ).format(z=8, x=72, y=110) + assert get_content(url) # just make sure it doesn't fail + + +def test_multiband_vmin_vmax(bahamas_file): + bahamas = RestTileClient(bahamas_file) + # Check that other options are well handled + url = bahamas.get_tile_url( + band=[3, 2, 1], + vmax=[100, 200, 250], + ).format(z=8, x=72, y=110) + assert get_content(url) # just make sure it doesn't fail + url = bahamas.get_tile_url( + band=[3, 2, 1], + vmin=[0, 10, 50], + vmax=[100, 200, 250], + ).format(z=8, x=72, y=110) + assert get_content(url) # just make sure it doesn't fail + with pytest.raises(ValueError): + bahamas.get_tile_url( + vmax=[100, 200, 250], + ).format(z=8, x=72, y=110) + + +def test_launch_non_default_server(bahamas_file): + default = RestTileClient(bahamas_file) + diff = RestTileClient(bahamas_file, port=0) + assert default.server != diff.server + assert default.server_port != diff.server_port + + +def test_pixel(bahamas_file): + bahamas = RestTileClient(bahamas_file) + # pix = bahamas.pixel(0, 0) # pixel space + # assert "bands" in pix + pix = bahamas.pixel(24.56, -77.76, units="EPSG:4326") # world coordinates + assert "bands" in pix + + +@pytest.mark.skip +def test_histogram(bahamas_file): + bahamas = RestTileClient(bahamas_file) + hist = bahamas.histogram() + assert len(hist) + + +@pytest.mark.parametrize("encoding", ["PNG", "JPEG", "JPG"]) +def test_thumbnail_encodings(bahamas_file, encoding): + bahamas = RestTileClient(bahamas_file) + # large-image's rasterio source cannot handle: "TIF", "TIFF" + thumbnail = bahamas.thumbnail(encoding=encoding) + assert thumbnail # TODO: check content + assert isinstance(thumbnail, ImageBytes) + thumbnail = bahamas.thumbnail(encoding=encoding, output_path=True) + assert os.path.exists(thumbnail) + + +def test_thumbnail_bad_encoding(bahamas_file): + bahamas = RestTileClient(bahamas_file) + with pytest.raises(ValueError): + bahamas.thumbnail(encoding="foo") + + +def test_custom_palette(bahamas_file): + bahamas = RestTileClient(bahamas_file) + palette = ["#006633", "#E5FFCC", "#662A00", "#D8D8D8", "#F5F5F5"] + thumbnail = bahamas.thumbnail( + band=1, + palette=palette, + scheme="discrete", + ) + assert thumbnail # TODO: check colors in produced image + thumbnail = bahamas.thumbnail( + band=1, + cmap=palette, + scheme="linear", + ) + assert thumbnail # TODO: check colors in produced image + + +def test_style_dict(bahamas_file): + bahamas = RestTileClient(bahamas_file) + style = { + "bands": [ + {"band": 1, "palette": ["#000", "#0f0"]}, + ] + } + thumbnail = bahamas.thumbnail( + style=style, + ) + assert thumbnail # TODO: check colors in produced image + + +def test_rasterio_property(bahamas_file): + bahamas = RestTileClient(bahamas_file) + src = bahamas.rasterio + assert isinstance(src, rasterio.io.DatasetReaderBase) + assert src == bahamas.rasterio