diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 51beaf9..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bumpversion] -current_version = 0.12.1 -commit = True -tag = True -tag_name = {new_version} - -[bumpversion:file:tilebench/__init__.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" diff --git a/CHANGES.md b/CHANGES.md index ab31ac4..3af27a0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +## 0.13.0 (2024-10-23) + +* update rio-tiler dependency to `>=7.0,<8.0` +* add `reader-params` options in CLI + ## 0.12.1 (2024-04-18) * fix GET range parsing diff --git a/pyproject.toml b/pyproject.toml index 8f1e9d0..82a4bf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "jinja2>=3.0,<4.0.0", "loguru", "rasterio>=1.3.8", - "rio-tiler>=6.0,<7.0", + "rio-tiler>=7.0,<8.0", "uvicorn[standard]", ] @@ -38,6 +38,7 @@ test = [ ] dev = [ "pre-commit", + "bump-my-version", ] [project.urls] @@ -112,3 +113,18 @@ ignore = [ [tool.ruff.lint.mccabe] max-complexity = 14 + + +[tool.bumpversion] +current_version = "0.12.1" +search = "{current_version}" +replace = "{new_version}" +regex = false +tag = true +commit = true +tag_name = "{new_version}" + +[[tool.bumpversion.files]] +filename = "tilebench/__init__.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' diff --git a/tilebench/scripts/cli.py b/tilebench/scripts/cli.py index 674e338..65b60f5 100644 --- a/tilebench/scripts/cli.py +++ b/tilebench/scripts/cli.py @@ -11,7 +11,7 @@ from loguru import logger as log from rasterio._path import _parse_path as parse_path from rasterio.rio import options -from rio_tiler.io import BaseReader, COGReader, MultiBandReader, MultiBaseReader +from rio_tiler.io import BaseReader, MultiBandReader, MultiBaseReader, Reader from tilebench import profile as profiler from tilebench.viz import TileDebug @@ -19,6 +19,34 @@ default_tms = morecantile.tms.get("WebMercatorQuad") +def options_to_dict(ctx, param, value): + """ + click callback to validate `--opt KEY1=VAL1 --opt KEY2=VAL2` and collect + in a dictionary like the one below, which is what the CLI function receives. + If no value or `None` is received then an empty dictionary is returned. + + { + 'KEY1': 'VAL1', + 'KEY2': 'VAL2' + } + + Note: `==VAL` breaks this as `str.split('=', 1)` is used. + """ + + if not value: + return {} + else: + out = {} + for pair in value: + if "=" not in pair: + raise click.BadParameter(f"Invalid syntax for KEY=VAL arg: {pair}") + else: + k, v = pair.split("=", 1) + out[k] = v + + return out + + # The CLI command group. @click.group(help="Command line interface for the tilebench Python package.") def cli(): @@ -51,7 +79,7 @@ def cli(): @click.option( "--reader", type=str, - help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader`", + help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`", ) @click.option( "--tms", @@ -66,6 +94,15 @@ def cli(): callback=options._cb_key_val, help="GDAL configuration options.", ) +@click.option( + "--reader-params", + "-p", + "reader_params", + metavar="NAME=VALUE", + multiple=True, + callback=options_to_dict, + help="Reader Options.", +) def profile( input, tile, @@ -77,8 +114,9 @@ def profile( reader, tms, config, + reader_params, ): - """Profile COGReader Mercator Tile read.""" + """Profile Reader Tile read.""" tilematrixset = default_tms if tms: with open(tms, "r") as f: @@ -90,15 +128,17 @@ def profile( if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)): warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1) - Reader = reader or COGReader + DstReader = reader or Reader if not tile: with rasterio.Env(CPL_VSIL_CURL_NON_CACHED=parse_path(input).as_vsi()): - with Reader(input, tms=tilematrixset) as cog: + with Reader(input, tms=tilematrixset, **reader_params) as cog: if zoom is None: zoom = randint(cog.minzoom, cog.maxzoom) - w, s, e, n = cog.geographic_bounds + w, s, e, n = cog.get_geographic_bounds( + tilematrixset.rasterio_geographic_crs + ) # Truncate BBox to the TMS bounds w = max(tilematrixset.bbox.left, w) s = max(tilematrixset.bbox.bottom, s) @@ -128,7 +168,7 @@ def profile( config=config, ) def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256): - with Reader(src_path, tms=tilematrixset) as cog: + with DstReader(src_path, tms=tilematrixset, **reader_params) as cog: return cog.tile(x, y, z, tilesize=tilesize) (_, _), stats = _read_tile(input, tile_x, tile_y, tile_z, tilesize) @@ -141,14 +181,23 @@ def _read_tile(src_path: str, x: int, y: int, z: int, tilesize: int = 256): @click.option( "--reader", type=str, - help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader`", + help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`", ) @click.option( "--tms", help="Path to TileMatrixSet JSON file.", type=click.Path(), ) -def get_zooms(input, reader, tms): +@click.option( + "--reader-params", + "-p", + "reader_params", + metavar="NAME=VALUE", + multiple=True, + callback=options_to_dict, + help="Reader Options.", +) +def get_zooms(input, reader, tms, reader_params): """Get Mercator Zoom levels.""" tilematrixset = default_tms if tms: @@ -161,9 +210,9 @@ def get_zooms(input, reader, tms): if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)): warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1) - Reader = reader or COGReader + DstReader = reader or Reader - with Reader(input, tms=tilematrixset) as cog: + with DstReader(input, tms=tilematrixset, **reader_params) as cog: click.echo(json.dumps({"minzoom": cog.minzoom, "maxzoom": cog.maxzoom})) @@ -173,14 +222,23 @@ def get_zooms(input, reader, tms): @click.option( "--reader", type=str, - help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader`", + help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`", ) @click.option( "--tms", help="Path to TileMatrixSet JSON file.", type=click.Path(), ) -def random(input, zoom, reader, tms): +@click.option( + "--reader-params", + "-p", + "reader_params", + metavar="NAME=VALUE", + multiple=True, + callback=options_to_dict, + help="Reader Options.", +) +def random(input, zoom, reader, tms, reader_params): """Get random tile.""" tilematrixset = default_tms if tms: @@ -193,12 +251,12 @@ def random(input, zoom, reader, tms): if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)): warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1) - Reader = reader or COGReader + DstReader = reader or Reader - with Reader(input, tms=tilematrixset) as cog: + with DstReader(input, tms=tilematrixset, **reader_params) as cog: if zoom is None: zoom = randint(cog.minzoom, cog.maxzoom) - w, s, e, n = cog.geographic_bounds + w, s, e, n = cog.get_geographic_bounds(tilematrixset.rasterio_geographic_crs) # Truncate BBox to the TMS bounds w = max(tilematrixset.bbox.left, w) @@ -237,7 +295,7 @@ def random(input, zoom, reader, tms): @click.option( "--reader", type=str, - help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.COGReader`", + help="rio-tiler Reader (BaseReader). Default is `rio_tiler.io.Reader`", ) @click.option( "--config", @@ -247,21 +305,31 @@ def random(input, zoom, reader, tms): callback=options._cb_key_val, help="GDAL configuration options.", ) -def viz(src_path, port, host, server_only, reader, config): +@click.option( + "--reader-params", + "-p", + "reader_params", + metavar="NAME=VALUE", + multiple=True, + callback=options_to_dict, + help="Reader Options.", +) +def viz(src_path, port, host, server_only, reader, config, reader_params): """WEB UI to visualize VSI statistics for a web mercator tile requests.""" if reader: module, classname = reader.rsplit(".", 1) reader = getattr(importlib.import_module(module), classname) # noqa - if not issubclass(reader, (BaseReader)): + if not issubclass(reader, (BaseReader, MultiBandReader, MultiBaseReader)): warnings.warn(f"Invalid reader type: {type(reader)}", stacklevel=1) - Reader = reader or COGReader + DstReader = reader or Reader config = config or {} application = TileDebug( src_path=src_path, - reader=Reader, + reader=DstReader, + reader_params=reader_params, port=port, host=host, config=config, diff --git a/tilebench/viz.py b/tilebench/viz.py index ccd5a29..3afd862 100644 --- a/tilebench/viz.py +++ b/tilebench/viz.py @@ -124,6 +124,7 @@ class TileDebug: src_path: str = attr.ib() reader: Type[BaseReader] = attr.ib(default=Reader) + reader_params: Dict = attr.ib(factory=dict) app: FastAPI = attr.ib(default=attr.Factory(FastAPI)) @@ -172,7 +173,7 @@ def image( ], ): """Handle /image requests.""" - with self.reader(self.src_path) as src_dst: + with self.reader(self.src_path, **self.reader_params) as src_dst: img = src_dst.tile(x, y, z) return PNGResponse( @@ -216,7 +217,7 @@ def tile( config=self.config, ) def _read_tile(src_path: str, x: int, y: int, z: int): - with self.reader(src_path) as src_dst: + with self.reader(src_path, **self.reader_params) as src_dst: return src_dst.tile(x, y, z) with Timer() as t: @@ -241,42 +242,67 @@ def _read_tile(src_path: str, x: int, y: int, z: int): ) def info(): """Return a geojson.""" - with Reader(self.src_path) as src_dst: - width, height = src_dst.dataset.width, src_dst.dataset.height + with self.reader(self.src_path, **self.reader_params) as src_dst: + bounds = src_dst.get_geographic_bounds( + src_dst.tms.rasterio_geographic_crs + ) + + width, height = src_dst.width, src_dst.height + if not all([width, height]): + return bbox_to_feature( + bounds, + properties={ + "bounds": bounds, + "crs": src_dst.crs.to_epsg(), + "ifd": [], + }, + ) info = { "width": width, "height": height, + "bounds": bounds, + "crs": src_dst.crs.to_epsg(), } - info["bounds"] = src_dst.geographic_bounds - - info["crs"] = src_dst.dataset.crs.to_epsg() - ovr = src_dst.dataset.overviews(1) - info["overviews"] = len(ovr) dst_affine, _, _ = calculate_default_transform( - src_dst.dataset.crs, + src_dst.crs, tms.crs, width, height, - *src_dst.dataset.bounds, + *src_dst.bounds, ) + + # Raw resolution Zoom and IFD info resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) zoom = tms.zoom_for_res(resolution) info["maxzoom"] = zoom + try: + blocksize = src_dst.dataset.block_shapes[0] + except Exception: + blocksize = src_dst.width + ifd = [ { "Level": 0, "Width": width, "Height": height, - "Blocksize": src_dst.dataset.block_shapes[0], + "Blocksize": blocksize, "Decimation": 0, "MercatorZoom": zoom, "MercatorResolution": resolution, } ] + try: + ovr = src_dst.dataset.overviews(1) + except Exception: + ovr = [] + + info["overviews"] = len(ovr) + + # Overviews Zooms and IFD info for ix, decim in enumerate(ovr): with rasterio.open(self.src_path, OVERVIEW_LEVEL=ix) as ovr_dst: dst_affine, _, _ = calculate_default_transform( @@ -313,26 +339,35 @@ def info(): ) def grid(ovr_level: Annotated[int, Query(description="Overview Level")]): """return geojson.""" - options = {"OVERVIEW_LEVEL": ovr_level - 1} if ovr_level else {} - with rasterio.open(self.src_path, **options) as src_dst: - feats = [] - blockxsize, blockysize = src_dst.block_shapes[0] - winds = ( - windows.Window(col_off=col_off, row_off=row_off, width=w, height=h) - for row_off, h in dims(src_dst.height, blockysize) - for col_off, w in dims(src_dst.width, blockxsize) - ) - for window in winds: - fc = bbox_to_feature(windows.bounds(window, src_dst.transform)) - for feat in fc.get("features", []): - geom = transform_geom(src_dst.crs, WGS84_CRS, feat["geometry"]) - feats.append( - { - "type": "Feature", - "geometry": geom, - "properties": {"window": str(window)}, - } + # Will only work with Rasterio compatible dataset + try: + options = {"OVERVIEW_LEVEL": ovr_level - 1} if ovr_level else {} + with rasterio.open(self.src_path, **options) as src_dst: + feats = [] + blockxsize, blockysize = src_dst.block_shapes[0] + winds = ( + windows.Window( + col_off=col_off, row_off=row_off, width=w, height=h ) + for row_off, h in dims(src_dst.height, blockysize) + for col_off, w in dims(src_dst.width, blockxsize) + ) + for window in winds: + fc = bbox_to_feature(windows.bounds(window, src_dst.transform)) + for feat in fc.get("features", []): + geom = transform_geom( + src_dst.crs, WGS84_CRS, feat["geometry"] + ) + feats.append( + { + "type": "Feature", + "geometry": geom, + "properties": {"window": str(window)}, + } + ) + + except Exception: + feats = [] return {"type": "FeatureCollection", "features": feats}