Repository: banesullivan/localtileserver
Pull request #156 (2023-06-03 19:11:42)

An improved TileClient that avoids the REST interface where possible

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/banesullivan/localtileserver/pull/156?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bane+Sullivan) Report
> Merging [#156](https://app.codecov.io/gh/banesullivan/localtileserver/pull/156?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bane+Sullivan) (166e155) into [main](https://app.codecov.io/gh/banesullivan/localtileserver/commit/131c3ea827de67a51e63e7df1c7b13a2c8d45651?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bane+Sullivan) (131c3ea) will **decrease** coverage by `8.34%`.
> The diff coverage is `84.03%`.

:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please [install the Github App Integration](https://github.com/apps/codecov) for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bane+Sullivan).

```diff
@@            Coverage Diff             @@
##             main     #156      +/-   ##
==========================================
- Coverage   84.96%   76.62%   -8.34%     
==========================================
  Files          26       26              
  Lines        1377     1459      +82     
==========================================
- Hits         1170     1118      -52     
- Misses        207      341     +134     
```


Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index 0dfaf88..96fc246 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 f4065c5..6650704 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 b71b2ab..61f11c4 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 <https://earthpy.readthedocs.io/en/latest/gallery_vignettes/plot_dem_hillshade.html>`_
-
-
 .. note::
 
-  This example requires ``rasterio`` to be installed.
+  This example was adopted from `EarthPy <https://earthpy.readthedocs.io/en/latest/gallery_vignettes/plot_dem_hillshade.html>`_
 
 
 .. code:: python
diff --git a/doc/source/user-guide/index.rst b/doc/source/user-guide/index.rst
index d426d43..a79350f 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 9e562e2..f522252 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 b0e7419..a01f929 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 8ac42dd..1012bef 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 ef4a24f..81a834e 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 f5f37f9..6a03d91 100644
--- a/localtileserver/client.py
+++ b/localtileserver/client.py
@@ -4,10 +4,11 @@ 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.tilesource import FileTileSource
+from large_image_source_rasterio import RasterioFileTileSource
 import rasterio
 import requests
 
@@ -25,7 +26,18 @@ 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 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 @@ DEMO_REMOTE_TILE_SERVER = "https://tileserver.banesullivan.com/"
 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 @@ class BaseTileClient:
             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 @@ class RestfulTileClient(BaseTileClient):
         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 @@ class RemoteTileClient(RestfulTileClient):
         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 @@ class TileClient(RestfulTileClient):
     ):
         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 @@ class TileClient(RestfulTileClient):
             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 @@ class TileClient(RestfulTileClient):
         )
 
 
+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 5683213..5beaf95 100644
--- a/localtileserver/helpers.py
+++ b/localtileserver/helpers.py
@@ -4,7 +4,7 @@ import uuid
 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 776123e..5dad29e 100644
--- a/localtileserver/tiler/__init__.py
+++ b/localtileserver/tiler/__init__.py
@@ -10,4 +10,18 @@ from localtileserver.tiler.data import (
     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 dabaf32..1707a95 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 31dca2c..f9bda81 100644
--- a/localtileserver/tiler/utilities.py
+++ b/localtileserver/tiler/utilities.py
@@ -7,8 +7,7 @@ from typing import Optional, Union
 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 697976c..ad55ac6 100644
--- a/localtileserver/utilities.py
+++ b/localtileserver/utilities.py
@@ -4,7 +4,7 @@ from urllib.parse import urlencode
 
 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 8aef441..2e83478 100644
--- a/localtileserver/validate.py
+++ b/localtileserver/validate.py
@@ -2,22 +2,22 @@ import logging
 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 1462089..3c03209 100644
--- a/localtileserver/web/application.py
+++ b/localtileserver/web/application.py
@@ -8,7 +8,7 @@ import click
 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 43cd56b..9bdae43 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 b12fc8d..b6f5b8c 100644
--- a/localtileserver/web/rest.py
+++ b/localtileserver/web/rest.py
@@ -14,14 +14,10 @@ from large_image.exceptions import (
     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 @@ from localtileserver.tiler.utilities import (
     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 fcfe006..2604a05 100644
--- a/localtileserver/web/utils.py
+++ b/localtileserver/web/utils.py
@@ -2,8 +2,8 @@ import logging
 
 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 bc3bc84..be0361b 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ else:
     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))
 


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index faf8618..83f905a 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -3,14 +3,20 @@ import os
 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 0000000..7da09c5
--- /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