Source code for pysepal.mapping.tiling

"""Prepare a raster for fast tile serving.

``rio-tiler`` reads full-resolution pixels for every tile when the source has no
overviews, so low zooms are slow and each tile logs a ``NoOverviewWarning``.
:func:`prepare_for_tiles` returns a tiled COG with overviews, kept in a cache
directory and reused on later calls, and is a fast no-op when the raster is
already good enough.

Everything runs through rasterio's own GDAL rather than the ``gdal_translate``
/ ``gdaladdo`` / ``gdalwarp`` binaries: those cannot be declared as a dependency
-- they are not on PyPI -- so a ``pip install`` would silently fall back to
serving unprepared, unprojected rasters.

``rasterio`` is imported inside the functions that need it. ``pysepal.mapping``
pulls this module in, and importing rasterio costs ~160ms, which an app that only
draws Earth Engine layers should not pay to display a map.
"""

import contextlib
import hashlib
import logging
import os
import pathlib
import uuid
from typing import Iterator, Optional, Union

from pysepal.scripts.scratch import scratch_root

__all__ = ["analyze_tif", "prepare_for_tiles"]

log = logging.getLogger("sepalui.mapping.tiling")

#: Overrides where prepared rasters are cached, for a sandbox with a disk quota.
CACHE_DIR_ENV_VAR = "PYSEPAL_TILE_CACHE"

#: Edge of a tile block, and the size overviews are built down to.
BLOCK_SIZE = 512


[docs] def default_cache_dir() -> pathlib.Path: """The directory prepared rasters are cached in. A prepared COG is a derived file, so it belongs on scratch rather than in the user's home: on a SEPAL sandbox the home export is nfs4 and quota'd, which would make every tile read cross the network and charge the user for the copy. :func:`~pysepal.scripts.scratch.scratch_root` is container-local there. A fixed name under that root rather than :func:`scratch_dir`, which mints a unique directory per call and would defeat the caching entirely. Returns: ``$PYSEPAL_TILE_CACHE`` when set, otherwise ``pysepal-tiles`` under the scratch root. """ override = os.environ.get(CACHE_DIR_ENV_VAR) if override: return pathlib.Path(override) return scratch_root() / "pysepal-tiles"
def _optimize_for_tiles( image: Union[str, pathlib.Path], categorical: Optional[bool] = None, warp_to_3857: bool = False, ) -> str: """Return a tiling-optimized (cached COG with overviews) path. Best-effort wrapper around :func:`prepare_for_tiles`: on failure the raw raster is served, which still tiles, just more slowly. """ try: return prepare_for_tiles(str(image), warp_to_3857=warp_to_3857, categorical=categorical)[ "path" ] except Exception as e: log.warning("Tiling optimization failed for %s (%s); serving the raw raster.", image, e) return str(image) def _hash_for_cache(path: str, *recipe: str) -> str: """Identify a cache entry by its source *and* how it was prepared. ``recipe`` carries everything that changes the output for the same input -- overview resampling, class renumbering -- so a second call asking for different treatment does not get handed the first call's file. ``st_mtime_ns`` rather than whole seconds: a rewrite that keeps the byte count and lands inside the same second would otherwise look unchanged. """ st = os.stat(path) h = hashlib.sha1() h.update(path.encode()) h.update(str(st.st_size).encode()) h.update(str(st.st_mtime_ns).encode()) for item in recipe: h.update(b"\x00") h.update(item.encode()) return h.hexdigest()[:16] @contextlib.contextmanager def _write_atomically(dst: Union[str, pathlib.Path]) -> Iterator[str]: """Yield a temporary path to write, moved onto ``dst`` only once it is whole. Two kernels adding the same raster resolve to the same cache entry, and a reader that opens a half-written GeoTIFF fails in ways that look like a corrupt source. """ dst = pathlib.Path(dst) # unique per writer, not just per process: add_raster_async hands preparation # to a thread pool, so two concurrent adds share a pid and would otherwise # write the same file -- one publishing it while the other is mid-write tmp = dst.with_name(f"{dst.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.part") try: yield str(tmp) os.replace(tmp, dst) finally: with contextlib.suppress(OSError): if tmp.exists(): tmp.unlink() def _guess_categorical(ds) -> bool: """Guess whether a raster holds classes rather than a continuous measure. Only consulted when the caller doesn't say: passing ``class_colors`` to ``add_raster`` settles it, and this dtype test cannot tell a class map from an integer DEM. """ return ds.count == 1 and ds.dtypes[0].startswith( ("int8", "uint8", "int16", "uint16", "int32", "uint32") ) def _has_overviews(ds) -> bool: return any(ds.overviews(i + 1) for i in range(ds.count)) def _is_tiled(ds) -> bool: # block_shapes is None on some drivers; treat as not tiled try: bs = ds.block_shapes return bool(bs) and all((b[0] > 1 and b[1] > 1) for b in bs) except Exception: return False def _needs_reproject(ds, target_epsg: Optional[int]) -> bool: if not target_epsg or not ds.crs: return False try: return ds.crs.to_epsg() != target_epsg except Exception: return True def _predictor_for(dtype: str) -> int: """The DEFLATE predictor that matches the sample format. 2 is horizontal differencing, defined for integers only; floating point needs 3, and using 2 there either errors or compresses badly. """ return 3 if dtype.startswith("float") else 2
[docs] def analyze_tif(path: str) -> dict: """Report the tiling-relevant properties of a raster. Args: path: Path to the raster file. Returns: CRS, size, band count, dtype, whether it is tiled, its overview levels and a guess at whether the data is categorical. """ import rasterio as rio with rio.open(path) as ds: return { "path": path, "crs": str(ds.crs), "epsg": (ds.crs.to_epsg() if ds.crs else None), "width": ds.width, "height": ds.height, "bands": ds.count, "dtype": ds.dtypes[0], "tiled": _is_tiled(ds), "overviews": [ds.overviews(i + 1) for i in range(ds.count)], "categorical_guess": _guess_categorical(ds), }
def _write_cog(source, dst: str, resampling: str, dtype: str) -> None: """Copy a dataset (or a warping view of one) out as a COG with overviews. ``rasterio.shutil.copy`` is GDAL's ``CreateCopy``, so the pixels stream rather than landing in a numpy array first -- a raster far larger than memory still converts. """ from rasterio.shutil import copy as rio_copy rio_copy( source, dst, driver="COG", compress="DEFLATE", level=6, predictor=_predictor_for(dtype), blocksize=BLOCK_SIZE, overviews="AUTO", resampling=resampling, num_threads="ALL_CPUS", bigtiff="IF_SAFER", )
[docs] def prepare_for_tiles( path: str, cache_dir: Optional[str] = None, warp_to_3857: bool = False, force: bool = False, categorical: Optional[bool] = None, ) -> dict: """Return a tiling-optimized copy of a raster, building it if needed. The copy is cached under ``cache_dir`` and reused on later calls, so repeat adds of the same raster cost nothing. Args: path: Path to the source raster. cache_dir: Where to keep the optimized copy. Defaults to :func:`default_cache_dir`. warp_to_3857: Reproject to Web Mercator, in the same pass that writes the COG. force: Rebuild even when a cached copy is available. categorical: Whether the values are class codes, which decides between NEAREST and AVERAGE overview resampling. Guessed from the dtype when not given. Returns: ``{"path": optimized_path, "report": analyze_tif(optimized_path)}``. ``path`` is the source itself when no work was needed. """ import rasterio as rio from rasterio.enums import Resampling from rasterio.vrt import WarpedVRT path = os.path.abspath(path) rep = analyze_tif(path) if categorical is None: categorical = rep["categorical_guess"] resampling = "NEAREST" if categorical else "AVERAGE" # Open once (context-managed) instead of leaking a handle per rio.open call. with rio.open(path) as ds: need_reproj = _needs_reproject(ds, 3857) if warp_to_3857 else False good_enough = rep["tiled"] and _has_overviews(ds) and not need_reproj if good_enough and not force: return {"path": path, "report": rep} cache_dir = str(cache_dir or default_cache_dir()) os.makedirs(cache_dir, exist_ok=True) tag = _hash_for_cache(path, resampling) out = os.path.join( cache_dir, f"{os.path.basename(path)}.{tag}" + (".3857.cog.tif" if warp_to_3857 else ".cog.tif"), ) # the tag covers the source and the resampling, so an existing entry is this # exact raster already prepared the same way if os.path.exists(out) and not force: return {"path": out, "report": analyze_tif(out)} with rio.open(path) as ds, _write_atomically(out) as tmp: if warp_to_3857: # the VRT warps lazily, so reprojecting costs no intermediate file with WarpedVRT(ds, crs="EPSG:3857", resampling=Resampling[resampling.lower()]) as vrt: _write_cog(vrt, tmp, resampling, rep["dtype"]) else: _write_cog(ds, tmp, resampling, rep["dtype"]) return {"path": out, "report": analyze_tif(out)}