diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 739c625..8865cc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,11 @@ jobs: strategy: matrix: python-version: - - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' + - '3.13' steps: - uses: actions/checkout@v4 @@ -88,57 +88,3 @@ jobs: HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} run: | python -m hatch publish - - - publish-docker: - needs: [tests] - if: github.ref == 'refs/heads/main' || startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Github - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set tag version - id: tag - run: | - echo "version=${GITHUB_REF#refs/*/}" - echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - - # Push `latest` when commiting to main - - name: Build and push - if: github.ref == 'refs/heads/main' - uses: docker/build-push-action@v2 - with: - # See https://github.com/developmentseed/titiler/discussions/387 - platforms: linux/amd64 - context: . - file: Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}:latest - - # Push `{VERSION}` when pushing a new tag - - name: Build and push - if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' - uses: docker/build-push-action@v2 - with: - # See https://github.com/developmentseed/titiler/discussions/387 - platforms: linux/amd64 - context: . - file: Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }} diff --git a/CHANGES.md b/CHANGES.md index cc44495..9438f03 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,9 @@ +# 0.13.0 (2025-01-20) + +* remove python 3.8 support +* update titiler dependency to `>=0.20,<0.21` +* remove docker image publishing + # 0.12.2 (2024-04-24) * update titiler dependency to `>=0.16,<0.19` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 02eaa6f..0000000 --- a/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -ARG PYTHON_VERSION=3.9 - -FROM python:${PYTHON_VERSION}-slim - -RUN apt-get update - -COPY rio_viz rio_viz -COPY pyproject.toml pyproject.toml -COPY README.md README.md -COPY LICENSE LICENSE - -RUN pip install . rasterio>=1.3b1 --no-cache-dir --upgrade - -# We add additional readers provided by rio-tiler-pds -RUN pip install rio-tiler-pds - -ENV GDAL_INGESTED_BYTES_AT_OPEN 32768 -ENV GDAL_DISABLE_READDIR_ON_OPEN EMPTY_DIR -ENV GDAL_HTTP_MERGE_CONSECUTIVE_RANGES YES -ENV GDAL_HTTP_MULTIPLEX YES -ENV GDAL_HTTP_VERSION 2 -ENV VSI_CACHE TRUE -ENV VSI_CACHE_SIZE 536870912 diff --git a/README.md b/README.md index c02c9c5..3e9fd9a 100644 --- a/README.md +++ b/README.md @@ -169,25 +169,6 @@ rio-viz supports Mapbox VectorTiles encoding from a raster array. This feature w ![](https://user-images.githubusercontent.com/10407788/56853984-4713b800-68fd-11e9-86a2-efbb041daeb0.gif) -## Docker - -Ready to use docker image can be found on Github registry. - -- https://github.com/developmentseed/rio-viz/pkgs/container/rio-viz - -```bash -docker run \ - --volume "$PWD":/data \ - --platform linux/amd64 \ - --rm -it -p 8080:8080 ghcr.io/developmentseed/rio-viz:latest \ - rio viz --host 0.0.0.0 /data/your-file.tif -``` - -Notes: -- `--platform linux/amd64` is only needed if you are using latest MacOS M1 machines -- `--volume "$PWD":/data` is needed to mount your local directory to the docker image -- rio-viz's option `--host 0.0.0.0` is required to access the web server - ## Contribution & Development See [CONTRIBUTING.md](https://github.com/developmentseed/rio-viz/blob/main/CONTRIBUTING.md) diff --git a/pyproject.toml b/pyproject.toml index c9fcd4a..b767127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rio-viz" description = "Visualize Cloud Optimized GeoTIFF in browser" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = {file = "LICENSE"} authors = [ {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, @@ -11,26 +11,26 @@ classifiers = [ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: GIS", ] dynamic = ["version"] dependencies = [ "braceexpand", "rio-cogeo>=5.0", - "titiler.core>=0.16.0,<0.19", - "starlette-cramjam>=0.3,<0.4", + "titiler.core>=0.20.0,<0.21", + "starlette-cramjam>=0.4,<0.5", "uvicorn", "server-thread>=0.2.0", ] [project.optional-dependencies] mvt = [ - "rio-tiler-mvt>=0.1,<0.2", + "rio-tiler-mvt>=0.2,<0.3", ] test = [ "pytest", diff --git a/rio_viz/app.py b/rio_viz/app.py index e7bfd4f..25bc158 100644 --- a/rio_viz/app.py +++ b/rio_viz/app.py @@ -9,6 +9,8 @@ import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Path, Query from geojson_pydantic.features import Feature +from geojson_pydantic.geometries import MultiPolygon, Polygon +from rio_tiler.constants import WGS84_CRS from rio_tiler.io import BaseReader, MultiBandReader, MultiBaseReader, Reader from rio_tiler.models import BandStatistics, Info from server_thread import ServerManager, ServerThread @@ -29,21 +31,28 @@ BandsExprParamsOptional, BandsParams, BidxExprParams, - ColorFormulaParams, ColorMapParams, + CoordCRSParams, + CRSParams, DatasetParams, DefaultDependency, + DstCRSParams, HistogramParams, ImageRenderingParams, PartFeatureParams, PreviewParams, - RescalingParams, StatisticsParams, + TileParams, ) from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers from titiler.core.middleware import CacheControlMiddleware from titiler.core.models.mapbox import TileJSON -from titiler.core.resources.responses import JSONResponse, XMLResponse +from titiler.core.models.responses import ( + InfoGeoJSON, + MultiBaseInfo, + MultiBaseInfoGeoJSON, +) +from titiler.core.resources.responses import GeoJSONResponse, JSONResponse, XMLResponse from titiler.core.utils import render_image try: @@ -70,12 +79,12 @@ class viz: reader: Union[Type[BaseReader], Type[MultiBandReader], Type[MultiBaseReader]] = ( attr.ib(default=Reader) ) - - app: FastAPI = attr.ib(default=attr.Factory(FastAPI)) + reader_params: Dict = attr.ib(factory=dict) + app: FastAPI = attr.ib(factory=FastAPI) port: int = attr.ib(default=8080) host: str = attr.ib(default="127.0.0.1") - config: Dict = attr.ib(default=dict) + config: Dict = attr.ib(factory=dict) minzoom: Optional[int] = attr.ib(default=None) maxzoom: Optional[int] = attr.ib(default=None) @@ -181,8 +190,7 @@ def register_routes(self): # noqa @self.router.get( "/info", # for MultiBaseReader the output in `Dict[str, Info]` - response_model=Dict[str, Info] if self.reader_type == "assets" else Info, - response_model_exclude={"minzoom", "maxzoom", "center"}, + response_model=MultiBaseInfo if self.reader_type == "assets" else Info, response_model_exclude_none=True, response_class=JSONResponse, responses={200: {"description": "Return the info of the COG."}}, @@ -190,10 +198,52 @@ def register_routes(self): # noqa ) def info(params=Depends(self.info_dependency)): """Handle /info requests.""" - with self.reader(self.src_path) as src_dst: + with self.reader(self.src_path, **self.reader_params) as src_dst: # Adapt options for each reader type self._update_params(src_dst, params) - return src_dst.info(**params) + return src_dst.info(**params.as_dict()) + + @self.router.get( + "/info.geojson", + # for MultiBaseReader the output in `Dict[str, Info]` + response_model=MultiBaseInfoGeoJSON + if self.reader_type == "assets" + else InfoGeoJSON, + response_model_exclude_none=True, + response_class=GeoJSONResponse, + responses={ + 200: { + "content": {"application/geo+json": {}}, + "description": "Return dataset's basic info as a GeoJSON feature.", + } + }, + tags=["API"], + ) + def info_geojson( + params=Depends(self.info_dependency), + crs=Depends(CRSParams), + ): + """Handle /info requests.""" + with self.reader(self.src_path, **self.reader_params) as src_dst: + bounds = src_dst.get_geographic_bounds(crs or WGS84_CRS) + if bounds[0] > bounds[2]: + pl = Polygon.from_bounds(-180, bounds[1], bounds[2], bounds[3]) + pr = Polygon.from_bounds(bounds[0], bounds[1], 180, bounds[3]) + geometry = MultiPolygon( + type="MultiPolygon", + coordinates=[pl.coordinates, pr.coordinates], + ) + else: + geometry = Polygon.from_bounds(*bounds) + + # Adapt options for each reader type + self._update_params(src_dst, params) + return Feature( + type="Feature", + bbox=bounds, + geometry=geometry, + properties=src_dst.info(**params.as_dict()), + ) @self.router.get( "/statistics", @@ -214,7 +264,7 @@ def statistics( histogram_params: HistogramParams = Depends(), ): """Handle /stats requests.""" - with self.reader(self.src_path) as src_dst: + with self.reader(self.src_path, **self.reader_params) as src_dst: if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -222,11 +272,11 @@ def statistics( self._update_params(src_dst, layer_params) return src_dst.statistics( - **layer_params, - **dataset_params, - **image_params, - **stats_params, - hist_options={**histogram_params}, + **layer_params.as_dict(), + **dataset_params.as_dict(), + **image_params.as_dict(), + **stats_params.as_dict(), + hist_options=histogram_params.as_dict(), ) @self.router.get( @@ -245,7 +295,7 @@ def point( ): """Handle /point requests.""" lon, lat = list(map(float, coordinates.split(","))) - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -255,13 +305,13 @@ def point( pts = src_dst.point( lon, lat, - **layer_params, - **dataset_params, + **layer_params.as_dict(), + **dataset_params.as_dict(), ) return { "coordinates": [lon, lat], - "values": pts.data.tolist(), + "values": pts.array.tolist(), "band_names": pts.band_names, } @@ -281,13 +331,11 @@ def preview( img_params: PreviewParams = Depends(), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), post_process=Depends(available_algorithms.dependency), ): """Handle /preview requests.""" - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -295,26 +343,20 @@ def preview( self._update_params(src_dst, layer_params) image = src_dst.preview( - **layer_params, - **dataset_params, - **img_params, + **layer_params.as_dict(), + **dataset_params.as_dict(), + **img_params.as_dict(), ) dst_colormap = getattr(src_dst, "colormap", None) if post_process: image = post_process(image) - if rescale: - image.rescale(rescale) - - if color_formula: - image.apply_color_formula(color_formula) - content, media_type = render_image( image, output_format=format, colormap=colormap or dst_colormap, - **render_params, + **render_params.as_dict(), ) return Response(content, media_type=media_type) @@ -353,13 +395,13 @@ def part( img_params: PartFeatureParams = Depends(), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), + dst_crs=Depends(DstCRSParams), + coord_crs=Depends(CoordCRSParams), post_process=Depends(available_algorithms.dependency), ): """Create image from part of a dataset.""" - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -368,26 +410,22 @@ def part( image = src_dst.part( [minx, miny, maxx, maxy], - **layer_params, - **dataset_params, - **img_params, + dst_crs=dst_crs, + bounds_crs=coord_crs or WGS84_CRS, + **layer_params.as_dict(), + **dataset_params.as_dict(), + **img_params.as_dict(), ) dst_colormap = getattr(src_dst, "colormap", None) if post_process: image = post_process(image) - if rescale: - image.rescale(rescale) - - if color_formula: - image.apply_color_formula(color_formula) - content, media_type = render_image( image, output_format=format, colormap=colormap or dst_colormap, - **render_params, + **render_params.as_dict(), ) return Response(content, media_type=media_type) @@ -415,13 +453,11 @@ def geojson_part( img_params: PartFeatureParams = Depends(), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), post_process=Depends(available_algorithms.dependency), ): """Handle /feature requests.""" - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -429,24 +465,20 @@ def geojson_part( self._update_params(src_dst, layer_params) image = src_dst.feature( - geom.model_dump(exclude_none=True), **layer_params, **dataset_params + geom.model_dump(exclude_none=True), + **layer_params.as_dict(), + **dataset_params.as_dict(), ) dst_colormap = getattr(src_dst, "colormap", None) if post_process: image = post_process(image) - if rescale: - image.rescale(rescale) - - if color_formula: - image.apply_color_formula(color_formula) - content, media_type = render_image( image, output_format=format, colormap=colormap or dst_colormap, - **render_params, + **render_params.as_dict(), ) return Response(content, media_type=media_type) @@ -462,8 +494,12 @@ def geojson_part( "description": "Read COG and return a tile", } - @self.router.get("/tiles/{z}/{x}/{y}", **tile_params, tags=["API"]) - @self.router.get("/tiles/{z}/{x}/{y}.{format}", **tile_params, tags=["API"]) + @self.router.get( + "/tiles/WebMercatorQuad/{z}/{x}/{y}", **tile_params, tags=["API"] + ) + @self.router.get( + "/tiles/WebMercatorQuad/{z}/{x}/{y}.{format}", **tile_params, tags=["API"] + ) def tile( z: Annotated[ int, @@ -487,8 +523,7 @@ def tile( layer_params=Depends(self.layer_dependency), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), + tile_params: TileParams = Depends(), colormap: ColorMapParams = Depends(), feature_type: Annotated[ Optional[Literal["point", "polygon"]], @@ -508,7 +543,7 @@ def tile( tilesize = tilesize or default_tilesize - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore if self.nodata is not None and dataset_params.nodata is None: dataset_params.nodata = self.nodata @@ -520,8 +555,9 @@ def tile( y, z, tilesize=tilesize, - **layer_params, - **dataset_params, + **tile_params.as_dict(), + **layer_params.as_dict(), + **dataset_params.as_dict(), ) dst_colormap = getattr(src_dst, "colormap", None) @@ -554,17 +590,11 @@ def tile( if post_process: image = post_process(image) - if rescale: - image.rescale(rescale) - - if color_formula: - image.apply_color_formula(color_formula) - content, media_type = render_image( image, output_format=format, colormap=colormap or dst_colormap, - **render_params, + **render_params.as_dict(), ) return Response(content, media_type=media_type) @@ -585,8 +615,6 @@ def tilejson( layer_params=Depends(self.layer_dependency), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), post_process=Depends(available_algorithms.dependency), feature_type: Annotated[ @@ -613,9 +641,13 @@ def tilejson( if qs: tile_url += f"?{urllib.parse.urlencode(qs)}" - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore bounds = ( - self.bounds if self.bounds is not None else src_dst.geographic_bounds + self.bounds + if self.bounds is not None + else src_dst.get_geographic_bounds( + src_dst.tms.rasterio_geographic_crs + ) ) minzoom = self.minzoom if self.minzoom is not None else src_dst.minzoom maxzoom = self.maxzoom if self.maxzoom is not None else src_dst.maxzoom @@ -641,8 +673,6 @@ def wmts( layer_params=Depends(self.layer_dependency), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), post_process=Depends(available_algorithms.dependency), feature_type: Annotated[ @@ -673,9 +703,13 @@ def wmts( if qs: tiles_endpoint += f"?{urllib.parse.urlencode(qs)}" - with self.reader(self.src_path) as src_dst: # type: ignore + with self.reader(self.src_path, **self.reader_params) as src_dst: # type: ignore bounds = ( - self.bounds if self.bounds is not None else src_dst.geographic_bounds + self.bounds + if self.bounds is not None + else src_dst.get_geographic_bounds( + src_dst.tms.rasterio_geographic_crs + ) ) minzoom = self.minzoom if self.minzoom is not None else src_dst.minzoom maxzoom = self.maxzoom if self.maxzoom is not None else src_dst.maxzoom @@ -717,8 +751,6 @@ def map_viewer( layer_params=Depends(self.layer_dependency), dataset_params: DatasetParams = Depends(), render_params: ImageRenderingParams = Depends(), - rescale: RescalingParams = Depends(), - color_formula: ColorFormulaParams = Depends(), colormap: ColorMapParams = Depends(), post_process=Depends(available_algorithms.dependency), tilesize: Annotated[ @@ -768,7 +800,7 @@ def viewer(request: Request): context={ "tilejson_endpoint": str(request.url_for("tilejson")), "stats_endpoint": str(request.url_for("statistics")), - "info_endpoint": str(request.url_for("info")), + "info_endpoint": str(request.url_for("info_geojson")), "point_endpoint": str(request.url_for("point")), "allow_3d": has_mvt, }, diff --git a/rio_viz/io/mosaic.py b/rio_viz/io/mosaic.py index d88e33b..3b8e52b 100644 --- a/rio_viz/io/mosaic.py +++ b/rio_viz/io/mosaic.py @@ -40,7 +40,7 @@ def __attrs_post_init__(self): self.maxzoom = max([cog.maxzoom for cog in self.datasets.values()]) self.crs = WGS84_CRS - bounds = [cog.geographic_bounds for cog in self.datasets.values()] + bounds = [cog.get_geographic_bounds(WGS84_CRS) for cog in self.datasets.values()] minx, miny, maxx, maxy = zip(*bounds) self.bounds = [min(minx), min(miny), max(maxx), max(maxy)] diff --git a/rio_viz/io/reader.py b/rio_viz/io/reader.py index ab9ba56..cb034bc 100644 --- a/rio_viz/io/reader.py +++ b/rio_viz/io/reader.py @@ -1,45 +1,28 @@ """rio-viz multifile reader.""" -from typing import Any, Dict, List, Type +from typing import List, Type import attr from braceexpand import braceexpand -from morecantile import TileMatrixSet -from rio_tiler.constants import WEB_MERCATOR_TMS +from rio_tiler import io from rio_tiler.errors import InvalidBandName -from rio_tiler.io import BaseReader, MultiBandReader, MultiBaseReader, Reader from rio_tiler.types import AssetInfo @attr.s -class MultiFilesBandsReader(MultiBandReader): +class MultiFilesBandsReader(io.MultiBandReader): """Multiple Files as Bands.""" - input: Any = attr.ib() - tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) + reader: Type[io.BaseReader] = attr.ib(default=io.Reader, init=False) - reader_options: Dict = attr.ib(factory=dict) - reader: Type[BaseReader] = attr.ib(default=Reader) - - files: List[str] = attr.ib(init=False) - - minzoom: int = attr.ib() - maxzoom: int = attr.ib() - - @minzoom.default - def _minzoom(self): - return self.tms.minzoom - - @maxzoom.default - def _maxzoom(self): - return self.tms.maxzoom + _files: List[str] = attr.ib(init=False) def __attrs_post_init__(self): """Fetch Reference band to get the bounds.""" - self.files = list(braceexpand(self.input)) - self.bands = [f"b{ix + 1}" for ix in range(len(self.files))] + self._files = list(braceexpand(self.input)) + self.bands = [f"b{ix + 1}" for ix in range(len(self._files))] - with self.reader(self.files[0], tms=self.tms, **self.reader_options) as cog: + with self.reader(self._files[0], tms=self.tms, **self.reader_options) as cog: self.bounds = cog.bounds self.crs = cog.crs self.minzoom = cog.minzoom @@ -51,38 +34,23 @@ def _get_band_url(self, band: str) -> str: raise InvalidBandName(f"{band} is not valid") index = self.bands.index(band) - return self.files[index] + return self._files[index] @attr.s -class MultiFilesAssetsReader(MultiBaseReader): +class MultiFilesAssetsReader(io.MultiBaseReader): """Multiple Files as Assets.""" - input: Any = attr.ib() - tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - - reader_options: Dict = attr.ib(factory=dict) - reader: Type[BaseReader] = attr.ib(default=Reader) - - files: List[str] = attr.ib(init=False) - - minzoom: int = attr.ib() - maxzoom: int = attr.ib() - - @minzoom.default - def _minzoom(self): - return self.tms.minzoom + reader: Type[io.BaseReader] = attr.ib(default=io.Reader, init=False) - @maxzoom.default - def _maxzoom(self): - return self.tms.maxzoom + _files: List[str] = attr.ib(init=False) def __attrs_post_init__(self): """Fetch Reference band to get the bounds.""" - self.files = list(braceexpand(self.input)) - self.assets = [f"asset{ix + 1}" for ix in range(len(self.files))] + self._files = list(braceexpand(self.input)) + self.assets = [f"asset{ix + 1}" for ix in range(len(self._files))] - with self.reader(self.files[0], tms=self.tms, **self.reader_options) as cog: + with self.reader(self._files[0], tms=self.tms, **self.reader_options) as cog: self.bounds = cog.bounds self.crs = cog.crs self.minzoom = cog.minzoom @@ -94,4 +62,4 @@ def _get_asset_info(self, asset: str) -> AssetInfo: raise InvalidBandName(f"{asset} is not valid") index = self.assets.index(asset) - return AssetInfo(url=self.files[index]) + return AssetInfo(url=self._files[index]) diff --git a/rio_viz/scripts/cli.py b/rio_viz/scripts/cli.py index 6b5a304..65cd980 100644 --- a/rio_viz/scripts/cli.py +++ b/rio_viz/scripts/cli.py @@ -16,6 +16,34 @@ from rio_viz import app +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 + + @contextmanager def TemporaryRasterFile(suffix=".tif"): """Create temporary file.""" @@ -98,6 +126,15 @@ def convert(self, value, param, ctx): 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 viz( src_path, nodata, @@ -110,6 +147,7 @@ def viz( layers, server_only, config, + reader_params, ): """Rasterio Viz cli.""" if reader: @@ -141,6 +179,7 @@ def viz( application = app.viz( src_path=src_path, reader=dataset_reader, + reader_params=reader_params, port=port, host=host, config=config, diff --git a/rio_viz/templates/index.html b/rio_viz/templates/index.html index 17fa9ee..f179bdf 100644 --- a/rio_viz/templates/index.html +++ b/rio_viz/templates/index.html @@ -339,7 +339,8 @@ 'uint32': [0, 4294967295], 'int32': [-2147483648, 2147483647], 'float32': [-3.4028235e+38, 3.4028235e+38], - 'float64': [-1.7976931348623157e+308, 1.7976931348623157e+308] + 'float64': [-1.7976931348623157e+308, 1.7976931348623157e+308], + 'complex_int16': [-32768, 32767] } if ('{{ allow_3d }}' !== "True") { @@ -987,62 +988,6 @@ switchViz() } -const bboxPolygon = (bounds) => { - return { - 'type': 'Feature', - 'geometry': { - 'type': 'Polygon', - 'coordinates': [[ - [bounds[0], bounds[1]], - [bounds[2], bounds[1]], - [bounds[2], bounds[3]], - [bounds[0], bounds[3]], - [bounds[0], bounds[1]] - ]] - }, - 'properties': {} - } -} - -const addAOI = (bounds) => { - if (map.getLayer('aoi-polygon')) map.removeLayer('aoi-polygon') - if (map.getSource('aoi')) map.removeSource('aoi') - if (bounds[0] > bounds[2]) { - map.addSource('aoi', { - 'type': 'geojson', - 'data': { - "type": "FeatureCollection", - "features": [ - bboxPolygon([-180, bounds[1], bounds[2], bounds[3]]), - bboxPolygon([bounds[0], bounds[1], 180, bounds[3]]), - ] - } - }) - } else { - map.addSource('aoi', { - 'type': 'geojson', - 'data': { - "type": "FeatureCollection", - "features": [bboxPolygon(bounds)] - } - }) - } - - map.addLayer({ - id: 'aoi-polygon', - type: 'line', - source: 'aoi', - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': '#3bb2d0', - 'line-width': 1 - } - }) - return -} map.on('load', () => { map.on('mousemove', (e) => { @@ -1128,9 +1073,8 @@ }) .then(data => { console.log(data) - scope.data_type = data.dtype - scope.colormap = data.colormap - scope.bounds = data.bounds + scope.data_type = data.properties.dtype + scope.colormap = data.properties.colormap if (['uint8','int8'].indexOf(scope.data_type) === -1 && !scope.colormap) document.getElementById('minmax-data').classList.remove('none') @@ -1138,8 +1082,8 @@ document.getElementById('data-min').value = mm[0] document.getElementById('data-max').value = mm[1] - scope.band_descriptions = data.band_descriptions - const band_descr = data.band_descriptions + scope.band_descriptions = data.properties.band_descriptions + const band_descr = data.properties.band_descriptions const nbands = band_descr.length //Populate Band (1b) selector @@ -1192,16 +1136,24 @@ document.getElementById('hide-arrow').classList.toggle('off') document.getElementById('menu').classList.toggle('off') - let bounds = [...data.bounds] + let bounds = [...data.bbox] + scope.bounds = bounds + // Bounds crossing dateline if (bounds[0] > bounds[2]) { - scope.crossing_dateline = true bounds[0] = bounds[0] - 360 + scope.crossing_dateline = true } - map.fitBounds( - [[bounds[0], bounds[1]], [bounds[2], bounds[3]]] - ) - addAOI(data.bounds) + + map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]]) + map.addSource('aoi', {'type': 'geojson', 'data': data}) + map.addLayer({ + id: 'aoi-polygon', + type: 'line', + source: 'aoi', + layout: {'line-cap': 'round', 'line-join': 'round'}, + paint: {'line-color': '#3bb2d0', 'line-width': 1} + }) if (nbands === 1) { document.getElementById('3b').classList.add('disabled') diff --git a/tests/test_app.py b/tests/test_app.py index 34311ae..53c7772 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -38,44 +38,50 @@ def test_viz(): assert response.status_code == 200 assert response.headers["cache-control"] == "no-cache" - response = client.get("/tiles/7/64/43.png?rescale=1,10") + response = client.get("/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10") assert response.status_code == 200 assert response.headers["content-type"] == "image/png" assert response.headers["cache-control"] == "no-cache" response = client.get( - "/tiles/7/64/43.png?rescale=1,10&bidx=1&color_formula=Gamma R 3" + "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&bidx=1&color_formula=Gamma R 3" ) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" - response = client.get("/tiles/7/64/43.png?rescale=1,10&bidx=1&bidx=1&bidx=1") + response = client.get( + "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&bidx=1&bidx=1&bidx=1" + ) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" - response = client.get("/tiles/7/64/43.png?rescale=1,10&colormap_name=cfastie") + response = client.get( + "/tiles/WebMercatorQuad/7/64/43.png?rescale=1,10&colormap_name=cfastie" + ) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" - response = client.get("/tiles/7/64/43?rescale=1,10&colormap_name=cfastie") + response = client.get( + "/tiles/WebMercatorQuad/7/64/43?rescale=1,10&colormap_name=cfastie" + ) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" - response = client.get("/tiles/18/8624/119094.png") + response = client.get("/tiles/WebMercatorQuad/18/8624/119094.png") assert response.status_code == 404 - response = client.get("/tiles/18/8624/119094.pbf") + response = client.get("/tiles/WebMercatorQuad/18/8624/119094.pbf") assert response.status_code == 404 - response = client.get("/tiles/7/64/43.pbf") + response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf") assert response.status_code == 500 assert not response.headers.get("cache-control") - response = client.get("/tiles/7/64/43.pbf?feature_type=polygon") + response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf?feature_type=polygon") assert response.status_code == 200 assert response.headers["content-type"] == "application/x-protobuf" - response = client.get("/tiles/7/64/43.pbf?feature_type=point") + response = client.get("/tiles/WebMercatorQuad/7/64/43.pbf?feature_type=point") assert response.status_code == 200 assert response.headers["content-type"] == "application/x-protobuf" @@ -387,7 +393,7 @@ def test_viz_mosaic(): assert response.status_code == 200 assert response.headers["content-type"] == "application/json" - response = client.get("/tiles/8/75/91?rescale=1,10") + response = client.get("/tiles/WebMercatorQuad/8/75/91?rescale=1,10") assert response.status_code == 200 assert response.headers["content-type"] == "image/png" assert response.headers["cache-control"] == "no-cache"