diff --git a/CHANGES.md b/CHANGES.md index bfbe92632..f70830427 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,6 +35,9 @@ coordinates: list[float] assets: list[AssetPoint] ``` +* add `/feature`, `/bbox` and `/statistics` optional endpoints +* make `cogeo-mosaic` an optional dependency **breaking change** +* remove default for `MosaicTilerFactory.backend` attribute **breaking change** ## 0.26.0 (2025-11-25) diff --git a/pyproject.toml b/pyproject.toml index 26be60090..a309515f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "titiler-core", "titiler-xarray", "titiler-extensions", - "titiler-mosaic", + "titiler-mosaic[mosaicjson]", "titiler-application", ] diff --git a/src/titiler/application/pyproject.toml b/src/titiler/application/pyproject.toml index 63f58a702..79c74be77 100644 --- a/src/titiler/application/pyproject.toml +++ b/src/titiler/application/pyproject.toml @@ -31,7 +31,7 @@ dynamic = ["version"] dependencies = [ "titiler-core[telemetry]==0.26.0", "titiler-extensions[cogeo,stac]==0.26.0", - "titiler-mosaic==0.26.0", + "titiler-mosaic[mosaicjson]==0.26.0", "starlette-cramjam>=0.4,<0.6", "pydantic-settings~=2.0", ] diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index e37939b37..ebc167cd3 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -7,9 +7,12 @@ import jinja2 import rasterio +from cogeo_mosaic.backends import MosaicBackend as MosaicJSONBackend +from cogeo_mosaic.errors import MosaicAuthError, MosaicError, MosaicNotFoundError from fastapi import Depends, FastAPI, HTTPException, Query, Security from fastapi.security.api_key import APIKeyQuery from rio_tiler.io import Reader, STACReader +from starlette import status from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.templating import Jinja2Templates @@ -170,6 +173,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): # Mosaic endpoints if not api_settings.disable_mosaic: mosaic = MosaicTilerFactory( + backend=MosaicJSONBackend, router_prefix="/mosaicjson", extensions=[ MosaicJSONExtension(), @@ -214,6 +218,15 @@ def validate_access_token(access_token: str = Security(api_key_query)): add_exception_handlers(app, DEFAULT_STATUS_CODES) + +# Add Mosaic specific error handlers +MOSAIC_STATUS_CODES.update( + { + MosaicAuthError: status.HTTP_401_UNAUTHORIZED, + MosaicError: status.HTTP_424_FAILED_DEPENDENCY, + MosaicNotFoundError: status.HTTP_404_NOT_FOUND, + } +) add_exception_handlers(app, MOSAIC_STATUS_CODES) # Set all CORS enabled origins diff --git a/src/titiler/core/titiler/core/factory.py b/src/titiler/core/titiler/core/factory.py index 54b401f27..41307fdf2 100644 --- a/src/titiler/core/titiler/core/factory.py +++ b/src/titiler/core/titiler/core/factory.py @@ -701,7 +701,14 @@ async def tileset_list( response_model=TileSet, response_class=JSONResponse, response_model_exclude_none=True, - responses={200: {"content": {"application/json": {}}}}, + responses={ + 200: { + "content": { + "application/json": {}, + "text/html": {}, + } + } + }, summary="Retrieve the raster tileset metadata for the specified dataset and tiling scheme (tile matrix set).", operation_id=f"{self.operation_prefix}getTileSet", ) diff --git a/src/titiler/mosaic/pyproject.toml b/src/titiler/mosaic/pyproject.toml index 8f5c811b0..822f28af1 100644 --- a/src/titiler/mosaic/pyproject.toml +++ b/src/titiler/mosaic/pyproject.toml @@ -30,6 +30,10 @@ classifiers = [ dynamic = ["version"] dependencies = [ "titiler-core==0.26.0", +] + +[project.optional-dependencies] +mosaicjson = [ "cogeo-mosaic>=9.0,<10.0", ] diff --git a/src/titiler/mosaic/tests/test_factory.py b/src/titiler/mosaic/tests/test_factory.py index d5a8f05c8..fb7564326 100644 --- a/src/titiler/mosaic/tests/test_factory.py +++ b/src/titiler/mosaic/tests/test_factory.py @@ -5,13 +5,14 @@ from contextlib import contextmanager from dataclasses import dataclass from io import BytesIO -from typing import Annotated, List, Optional +from typing import Annotated, Any, List, Optional from unittest.mock import patch import attr import morecantile import numpy from cogeo_mosaic.backends import FileBackend +from cogeo_mosaic.backends import MosaicBackend as MosaicJSONBackend from cogeo_mosaic.mosaic import MosaicJSON from fastapi import FastAPI, Query from rio_tiler.mosaic.methods import PixelSelectionMethod @@ -22,7 +23,7 @@ from titiler.mosaic.extensions import MosaicJSONExtension from titiler.mosaic.factory import MosaicTilerFactory -from .conftest import DATA_DIR +from .conftest import DATA_DIR, parse_img assets = [os.path.join(DATA_DIR, asset) for asset in ["cog1.tif", "cog2.tif"]] DEFAULT_TMS = morecantile.tms @@ -47,12 +48,24 @@ def tmpmosaic(): def test_MosaicTilerFactory(): """Test MosaicTilerFactory class.""" mosaic = MosaicTilerFactory( + backend=MosaicJSONBackend, optional_headers=[OptionalHeader.x_assets], router_prefix="mosaic", ) assert len(mosaic.router.routes) == 15 + @dataclass + class MosaicJSONAccessor(DefaultDependency): + """MosaicJSON Accessor Options.""" + + reverse: Annotated[ + bool, + Query(), + ] = False + mosaic = MosaicTilerFactory( + backend=MosaicJSONBackend, + assets_accessor_dependency=MosaicJSONAccessor, optional_headers=[ OptionalHeader.x_assets, OptionalHeader.server_timing, @@ -60,9 +73,11 @@ def test_MosaicTilerFactory(): extensions=[ MosaicJSONExtension(), ], + add_statistics=True, + add_part=True, router_prefix="mosaic", ) - assert len(mosaic.router.routes) == 17 + assert len(mosaic.router.routes) == 23 app = FastAPI() app.include_router(mosaic.router, prefix="/mosaic") @@ -111,7 +126,6 @@ def test_MosaicTilerFactory(): assert response.status_code == 200 assert response.json()["coordinates"] v = response.json()["assets"] - # one asset found assert len(v) == 1 assert v[0]["name"] @@ -120,6 +134,30 @@ def test_MosaicTilerFactory(): assert v[0]["band_names"] == ["b1", "b2", "b3"] assert v[0]["band_descriptions"] + response = client.get( + "/mosaic/point/-73,45", + params={"url": mosaic_file}, + ) + assert response.status_code == 200 + assert response.json()["coordinates"] + v = response.json()["assets"] + # two asset found + assert len(v) == 2 + names = [asset["name"] for asset in v] + assert names == assets + + response = client.get( + "/mosaic/point/-73,45", + params={"url": mosaic_file, "reverse": True}, + ) + assert response.status_code == 200 + assert response.json()["coordinates"] + v = response.json()["assets"] + # two asset found + assert len(v) == 2 + names = [asset["name"] for asset in v] + assert names == list(reversed(assets)) + # Masked values response = client.get( "/mosaic/point/-75.759,46.3847", @@ -128,7 +166,6 @@ def test_MosaicTilerFactory(): assert response.status_code == 200 assert response.json()["coordinates"] v = response.json()["assets"] - # one asset found assert len(v) == 1 assert v[0]["name"] @@ -248,10 +285,13 @@ def test_MosaicTilerFactory(): "/mosaic/point/-71,46/assets", params={"url": mosaic_file} ) assert response.status_code == 200 - assert all( - filepath.split("/")[-1] in ["cog1.tif", "cog2.tif"] - for filepath in response.json() + assert response.json() == assets + + response = client.get( + "/mosaic/point/-71,46/assets", params={"url": mosaic_file, "reverse": True} ) + assert response.status_code == 200 + assert response.json() == list(reversed(assets)) response = client.get( "/mosaic/point/-7903683.846322423,5780349.220256353/assets", @@ -302,6 +342,16 @@ def test_MosaicTilerFactory(): assert first_id in first_tms["title"] assert len(first_tms["links"]) == 2 # no link to the tms definition + response = client.get( + f"/mosaic/tiles?url={mosaic_file}", headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + response = client.get("/mosaic/tiles", params={"url": mosaic_file, "f": "html"}) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + response = client.get(f"/mosaic/tiles/WebMercatorQuad?url={mosaic_file}") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" @@ -309,6 +359,110 @@ def test_MosaicTilerFactory(): # covers only 3 zoom levels assert len(resp["tileMatrixSetLimits"]) == 3 + response = client.get( + f"/mosaic/tiles/WebMercatorQuad?url={mosaic_file}", + headers={"Accept": "text/html"}, + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + response = client.get( + "/mosaic/tiles/WebMercatorQuad", params={"url": mosaic_file, "f": "html"} + ) + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + response = client.get( + "/mosaic/bbox/-74,45,-73,46.png", + params={"url": mosaic_file, "dst_crs": "EPSG:3857"}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["X-Assets"] + assert response.headers["Server-Timing"] + assert response.headers["Content-Crs"] + assert response.headers["Content-Bbox"] + + meta = parse_img(response.content) + assert meta["width"] == 258 + assert meta["height"] == 367 + + response = client.get( + "/mosaic/bbox/-74,45,-73,46/100x50.png", + params={"url": mosaic_file}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["X-Assets"] + assert response.headers["Server-Timing"] + assert response.headers["Content-Crs"] + assert response.headers["Content-Bbox"] + + meta = parse_img(response.content) + assert meta["width"] == 100 + assert meta["height"] == 50 + + # test /feature + + feat = { + "type": "Polygon", + "coordinates": [ + [ + [-74, 45], + [-74, 46], + [-73, 46], + [-73, 45], + [-74, 45], + ] + ], + } + response = client.post( + "/mosaic/feature", + params={"url": mosaic_file}, + json={"type": "Feature", "properties": {}, "geometry": feat}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.headers["X-Assets"] + assert response.headers["Server-Timing"] + assert response.headers["Content-Crs"] + assert response.headers["Content-Bbox"] + + meta = parse_img(response.content) + assert meta["width"] == 301 + assert meta["height"] == 301 + + response = client.post( + "/mosaic/feature", + params={"url": mosaic_file, "max_size": 200}, + json={"type": "Feature", "properties": {}, "geometry": feat}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + + meta = parse_img(response.content) + assert meta["width"] == 200 + assert meta["height"] == 200 + + response = client.post( + "/mosaic/feature.png", + params={"url": mosaic_file}, + json={"type": "Feature", "properties": {}, "geometry": feat}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + + response = client.post( + "/mosaic/feature/150x50.png", + params={"url": mosaic_file}, + json={"type": "Feature", "properties": {}, "geometry": feat}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + meta = parse_img(response.content) + assert meta["width"] == 150 + assert meta["height"] == 50 + @dataclass class BackendParams(DefaultDependency): @@ -346,8 +500,9 @@ def _multiply_by_two(data, mask): def test_MosaicTilerFactory_PixelSelectionParams(): """Test MosaicTilerFactory factory with a customized default PixelSelectionMethod.""" - mosaic = MosaicTilerFactory(router_prefix="/mosaic") + mosaic = MosaicTilerFactory(backend=MosaicJSONBackend, router_prefix="/mosaic") mosaic_highest = MosaicTilerFactory( + backend=MosaicJSONBackend, pixel_selection_dependency=lambda: PixelSelectionMethod.highest.value, router_prefix="/mosaic_highest", ) @@ -381,7 +536,7 @@ def test_MosaicTilerFactory_PixelSelectionParams(): @patch("titiler.mosaic.factory.MOSAIC_STRICT_ZOOM", new=True) def test_MosaicTilerFactory_strict_zoom(): """Test MosaicTilerFactory factory with STRICT Zoom Mode""" - mosaic = MosaicTilerFactory() + mosaic = MosaicTilerFactory(backend=MosaicJSONBackend) app = FastAPI() app.include_router(mosaic.router) @@ -417,7 +572,12 @@ class CustomBackend(FileBackend): """Custom FileBackend.""" def get_assets( - self, x: int, y: int, z: int, limit: Optional[int] = None + self, + x: int, + y: int, + z: int, + limit: Optional[int] = None, + **kwargs: Any, ) -> List[str]: """Find assets.""" assets = super().get_assets(x, y, z) diff --git a/src/titiler/mosaic/titiler/mosaic/errors.py b/src/titiler/mosaic/titiler/mosaic/errors.py index 284785be8..3c50094ad 100644 --- a/src/titiler/mosaic/titiler/mosaic/errors.py +++ b/src/titiler/mosaic/titiler/mosaic/errors.py @@ -1,18 +1,9 @@ """Titiler mosaic errors.""" -from cogeo_mosaic.errors import ( - MosaicAuthError, - MosaicError, - MosaicNotFoundError, - NoAssetFoundError, -) -from rio_tiler.errors import EmptyMosaicError +from rio_tiler.errors import EmptyMosaicError, NoAssetFoundError from starlette import status MOSAIC_STATUS_CODES = { - MosaicAuthError: status.HTTP_401_UNAUTHORIZED, EmptyMosaicError: status.HTTP_204_NO_CONTENT, - MosaicNotFoundError: status.HTTP_404_NOT_FOUND, NoAssetFoundError: status.HTTP_204_NO_CONTENT, - MosaicError: status.HTTP_424_FAILED_DEPENDENCY, } diff --git a/src/titiler/mosaic/titiler/mosaic/extensions.py b/src/titiler/mosaic/titiler/mosaic/extensions.py index 527a6a922..fff2620f7 100644 --- a/src/titiler/mosaic/titiler/mosaic/extensions.py +++ b/src/titiler/mosaic/titiler/mosaic/extensions.py @@ -4,7 +4,6 @@ from dataclasses import dataclass import rasterio -from cogeo_mosaic.mosaic import MosaicJSON from fastapi import Depends from titiler.core.factory import FactoryExtension @@ -13,6 +12,12 @@ logger = logging.getLogger(__name__) +try: + from cogeo_mosaic.mosaic import MosaicJSON +except ImportError: # pragma: nocover + MosaicJSON = None # type: ignore + + @dataclass class MosaicJSONExtension(FactoryExtension): """Add MosaicJSON specific endpoints""" diff --git a/src/titiler/mosaic/titiler/mosaic/factory.py b/src/titiler/mosaic/titiler/mosaic/factory.py index 3e6a7bb67..d3b3ba850 100644 --- a/src/titiler/mosaic/titiler/mosaic/factory.py +++ b/src/titiler/mosaic/titiler/mosaic/factory.py @@ -19,9 +19,8 @@ import rasterio from attrs import define, field -from cogeo_mosaic.backends import MosaicBackend as MosaicJSONBackend -from fastapi import Depends, HTTPException, Path, Query -from geojson_pydantic.features import Feature +from fastapi import Body, Depends, HTTPException, Path, Query +from geojson_pydantic.features import Feature, FeatureCollection from geojson_pydantic.geometries import Polygon from morecantile import tms as morecantile_tms from morecantile.defaults import TileMatrixSets @@ -47,15 +46,25 @@ CRSParams, DatasetParams, DefaultDependency, + DstCRSParams, + HistogramParams, ImageRenderingParams, + PartFeatureParams, + StatisticsParams, TileParams, ) from titiler.core.factory import BaseFactory, img_endpoint_params from titiler.core.models.mapbox import TileJSON from titiler.core.models.OGC import TileSet, TileSetList -from titiler.core.resources.enums import ImageType, OptionalHeader +from titiler.core.models.responses import StatisticsGeoJSON +from titiler.core.resources.enums import ImageType, MediaType, OptionalHeader from titiler.core.resources.responses import GeoJSONResponse, JSONResponse, XMLResponse -from titiler.core.utils import bounds_to_geometry, render_image +from titiler.core.utils import ( + accept_media_type, + bounds_to_geometry, + create_html_response, + render_image, +) from titiler.mosaic.models.responses import Point MOSAIC_THREADS = int(os.getenv("MOSAIC_CONCURRENCY", MAX_THREADS)) @@ -88,7 +97,7 @@ def DatasetPathParams(url: Annotated[str, Query(description="Mosaic URL")]) -> s class MosaicTilerFactory(BaseFactory): """MosaicTiler Factory.""" - backend: Type[BaseBackend] = MosaicJSONBackend + backend: Type[BaseBackend] backend_dependency: Type[DefaultDependency] = DefaultDependency dataset_reader: Union[ @@ -118,6 +127,13 @@ class MosaicTilerFactory(BaseFactory): available_algorithms.dependency ) + # Statistics/Histogram Dependencies + stats_dependency: Type[DefaultDependency] = StatisticsParams + histogram_dependency: Type[DefaultDependency] = HistogramParams + + # Crop endpoints Dependencies + img_part_dependency: Type[DefaultDependency] = PartFeatureParams + # Image rendering Dependencies colormap_dependency: Callable[..., Optional[ColorMapType]] = ColorMapParams render_dependency: Type[DefaultDependency] = ImageRenderingParams @@ -135,6 +151,8 @@ class MosaicTilerFactory(BaseFactory): # Add/Remove some endpoints add_viewer: bool = True + add_statistics: bool = False + add_part: bool = False conforms_to: Set[str] = field( factory=lambda: { @@ -163,6 +181,12 @@ def register_routes(self): self.point() self.assets() + if self.add_part: + self.part() + + if self.add_statistics: + self.statistics() + ############################################################################ # /info ############################################################################ @@ -243,7 +267,7 @@ def info_geojson( ############################################################################ # /tileset ############################################################################ - def tilesets(self): + def tilesets(self): # noqa: C901 """Register OGC tilesets endpoints.""" @self.router.get( @@ -255,6 +279,7 @@ def tilesets(self): 200: { "content": { "application/json": {}, + "text/html": {}, } } }, @@ -268,6 +293,12 @@ async def tileset_list( reader_params=Depends(self.reader_dependency), crs=Depends(CRSParams), env=Depends(self.environment_dependency), + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, ): """Retrieve a list of available raster tilesets for the specified dataset.""" with rasterio.Env(**env): @@ -345,6 +376,25 @@ async def tileset_list( tilesets.append(tileset) data = TileSetList.model_validate({"tilesets": tilesets}) + + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data.model_dump(exclude_none=True, mode="json"), + title="Tilesets", + template_name="tilesets", + templates=self.templates, + ) + return data @self.router.get( @@ -352,7 +402,14 @@ async def tileset_list( response_model=TileSet, response_class=JSONResponse, response_model_exclude_none=True, - responses={200: {"content": {"application/json": {}}}}, + responses={ + 200: { + "content": { + "application/json": {}, + "text/html": {}, + } + } + }, summary="Retrieve the raster tileset metadata for the specified dataset and tiling scheme (tile matrix set).", operation_id=f"{self.operation_prefix}getTileSet", ) @@ -368,6 +425,12 @@ async def tileset( backend_params=Depends(self.backend_dependency), reader_params=Depends(self.reader_dependency), env=Depends(self.environment_dependency), + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, ): """Retrieve the raster tileset metadata for the specified dataset and tiling scheme (tile matrix set).""" tms = self.supported_tms.get(tileMatrixSetId) @@ -483,6 +546,24 @@ async def tileset( } ) + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data, + title=tileMatrixSetId, + template_name="tileset", + templates=self.templates, + ) + return data ############################################################################ @@ -1000,10 +1081,10 @@ def point( lon, lat, coord_crs=coord_crs or WGS84_CRS, + search_options=assets_accessor_params.as_dict(), threads=MOSAIC_THREADS, **layer_params.as_dict(), **dataset_params.as_dict(), - **assets_accessor_params.as_dict(), ) return { @@ -1019,6 +1100,279 @@ def point( ], } + def statistics(self): + """Register /statistics endpoint.""" + + @self.router.post( + "/statistics", + response_model=StatisticsGeoJSON, + response_model_exclude_none=True, + response_class=GeoJSONResponse, + responses={ + 200: { + "content": {"application/geo+json": {}}, + "description": "Return statistics for geojson features.", + } + }, + operation_id=f"{self.operation_prefix}postStatisticsForGeoJSON", + ) + def geojson_statistics( + geojson: Annotated[ + Union[FeatureCollection, Feature], + Body(description="GeoJSON Feature or FeatureCollection."), + ], + src_path=Depends(self.path_dependency), + backend_params=Depends(self.backend_dependency), + reader_params=Depends(self.reader_dependency), + assets_accessor_params=Depends(self.assets_accessor_dependency), + coord_crs=Depends(CoordCRSParams), + dst_crs=Depends(DstCRSParams), + layer_params=Depends(self.layer_dependency), + dataset_params=Depends(self.dataset_dependency), + pixel_selection=Depends(self.pixel_selection_dependency), + image_params=Depends(self.img_part_dependency), + post_process=Depends(self.process_dependency), + stats_params=Depends(self.stats_dependency), + histogram_params=Depends(self.histogram_dependency), + env=Depends(self.environment_dependency), + ): + """Get Statistics from a geojson feature or featureCollection.""" + fc = geojson + if isinstance(fc, Feature): + fc = FeatureCollection(type="FeatureCollection", features=[geojson]) + + with rasterio.Env(**env): + logger.info( + f"opening data with backend: {self.backend} and reader {self.dataset_reader}" + ) + with self.backend( + src_path, + reader=self.dataset_reader, + reader_options=reader_params.as_dict(), + **backend_params.as_dict(), + ) as src_dst: + for i, feature in enumerate(fc.features): + shape = feature.model_dump(exclude_none=True) + + logger.info(f"feature {i}: reading data") + image, assets = src_dst.feature( + shape, + shape_crs=coord_crs or WGS84_CRS, + dst_crs=dst_crs, + align_bounds_with_dataset=True, + search_options=assets_accessor_params.as_dict(), + pixel_selection=pixel_selection, + threads=MOSAIC_THREADS, + **layer_params.as_dict(), + **dataset_params.as_dict(), + **image_params.as_dict(), + ) + + coverage_array = image.get_coverage_array( + shape, + shape_crs=coord_crs or WGS84_CRS, + ) + + if post_process: + logger.info(f"feature {i}: post processing image") + image = post_process(image) + + logger.info(f"feature {i}: calculating statistics") + stats = image.statistics( + **stats_params.as_dict(), + hist_options=histogram_params.as_dict(), + coverage=coverage_array, + ) + + feature.properties = feature.properties or {} + feature.properties.update({"statistics": stats}) + feature.properties.update({"used_assets": assets}) + + return fc.features[0] if isinstance(geojson, Feature) else fc + + def part(self): # noqa: C901 + """Register /bbox and /feature endpoint.""" + + # GET endpoints + @self.router.get( + "/bbox/{minx},{miny},{maxx},{maxy}.{format}", + operation_id=f"{self.operation_prefix}getDataForBoundingBoxWithFormat", + **img_endpoint_params, + ) + @self.router.get( + "/bbox/{minx},{miny},{maxx},{maxy}/{width}x{height}.{format}", + operation_id=f"{self.operation_prefix}getDataForBoundingBoxWithSizesAndFormat", + **img_endpoint_params, + ) + def bbox_image( + minx: Annotated[float, Path(description="Bounding box min X")], + miny: Annotated[float, Path(description="Bounding box min Y")], + maxx: Annotated[float, Path(description="Bounding box max X")], + maxy: Annotated[float, Path(description="Bounding box max Y")], + format: Annotated[ + ImageType, + Field( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg)." + ), + ], + src_path=Depends(self.path_dependency), + backend_params=Depends(self.backend_dependency), + reader_params=Depends(self.reader_dependency), + assets_accessor_params=Depends(self.assets_accessor_dependency), + coord_crs=Depends(CoordCRSParams), + dst_crs=Depends(DstCRSParams), + layer_params=Depends(self.layer_dependency), + dataset_params=Depends(self.dataset_dependency), + pixel_selection=Depends(self.pixel_selection_dependency), + image_params=Depends(self.img_part_dependency), + post_process=Depends(self.process_dependency), + colormap=Depends(self.colormap_dependency), + render_params=Depends(self.render_dependency), + env=Depends(self.environment_dependency), + ): + """Create image from a bbox.""" + with rasterio.Env(**env): + logger.info( + f"opening data with backend: {self.backend} and reader {self.dataset_reader}" + ) + with self.backend( + src_path, + reader=self.dataset_reader, + reader_options=reader_params.as_dict(), + **backend_params.as_dict(), + ) as src_dst: + image, assets = src_dst.part( + [minx, miny, maxx, maxy], + dst_crs=dst_crs, + bounds_crs=coord_crs or WGS84_CRS, + search_options=assets_accessor_params.as_dict(), + pixel_selection=pixel_selection, + threads=MOSAIC_THREADS, + **layer_params.as_dict(), + **dataset_params.as_dict(), + **image_params.as_dict(), + ) + dst_colormap = getattr(src_dst, "colormap", None) + + if post_process: + image = post_process(image) + + content, media_type = self.render_func( + image, + output_format=format, + colormap=colormap or dst_colormap, + **render_params.as_dict(), + ) + + headers: Dict[str, str] = {} + if OptionalHeader.x_assets in self.optional_headers: + headers["X-Assets"] = ",".join(assets) + + if image.bounds is not None: + headers["Content-Bbox"] = ",".join(map(str, image.bounds)) + if uri := CRS_to_uri(image.crs): + headers["Content-Crs"] = f"<{uri}>" + + if ( + OptionalHeader.server_timing in self.optional_headers + and image.metadata.get("timings") + ): + headers["Server-Timing"] = ", ".join( + [f"{name};dur={time}" for (name, time) in image.metadata["timings"]] + ) + + return Response(content, media_type=media_type, headers=headers) + + @self.router.post( + "/feature", + operation_id=f"{self.operation_prefix}postDataForGeoJSON", + **img_endpoint_params, + ) + @self.router.post( + "/feature.{format}", + operation_id=f"{self.operation_prefix}postDataForGeoJSONWithFormat", + **img_endpoint_params, + ) + @self.router.post( + "/feature/{width}x{height}.{format}", + operation_id=f"{self.operation_prefix}postDataForGeoJSONWithSizesAndFormat", + **img_endpoint_params, + ) + def feature_image( + geojson: Annotated[Union[Feature], Body(description="GeoJSON Feature.")], + src_path=Depends(self.path_dependency), + format: Annotated[ + ImageType, + Field( + description="Default will be automatically defined if the output image needs a mask (png) or not (jpeg)." + ), + ] = None, + backend_params=Depends(self.backend_dependency), + reader_params=Depends(self.reader_dependency), + assets_accessor_params=Depends(self.assets_accessor_dependency), + coord_crs=Depends(CoordCRSParams), + dst_crs=Depends(DstCRSParams), + layer_params=Depends(self.layer_dependency), + dataset_params=Depends(self.dataset_dependency), + image_params=Depends(self.img_part_dependency), + pixel_selection=Depends(self.pixel_selection_dependency), + post_process=Depends(self.process_dependency), + colormap=Depends(self.colormap_dependency), + render_params=Depends(self.render_dependency), + env=Depends(self.environment_dependency), + ): + """Create image from a geojson feature.""" + with rasterio.Env(**env): + logger.info( + f"opening data with backend: {self.backend} and reader {self.dataset_reader}" + ) + with self.backend( + src_path, + reader=self.dataset_reader, + reader_options=reader_params.as_dict(), + **backend_params.as_dict(), + ) as src_dst: + image, assets = src_dst.feature( + geojson.model_dump(exclude_none=True), + shape_crs=coord_crs or WGS84_CRS, + dst_crs=dst_crs, + search_options=assets_accessor_params.as_dict(), + pixel_selection=pixel_selection, + threads=MOSAIC_THREADS, + **layer_params.as_dict(), + **image_params.as_dict(), + **dataset_params.as_dict(), + ) + + if post_process: + image = post_process(image) + + content, media_type = self.render_func( + image, + output_format=format, + colormap=colormap, + **render_params.as_dict(), + ) + + headers: Dict[str, str] = {} + if OptionalHeader.x_assets in self.optional_headers: + headers["X-Assets"] = ",".join(assets) + + if image.bounds is not None: + headers["Content-Bbox"] = ",".join(map(str, image.bounds)) + if uri := CRS_to_uri(image.crs): + headers["Content-Crs"] = f"<{uri}>" + + if ( + OptionalHeader.server_timing in self.optional_headers + and image.metadata.get("timings") + ): + headers["Server-Timing"] = ", ".join( + [f"{name};dur={time}" for (name, time) in image.metadata["timings"]] + ) + + return Response(content, media_type=media_type, headers=headers) + def assets(self): """Register /assets endpoint.""" diff --git a/uv.lock b/uv.lock index 3754791ff..84fc5dbca 100644 --- a/uv.lock +++ b/uv.lock @@ -4358,6 +4358,9 @@ dependencies = [ { name = "rasterio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/42/b08c8fdb255825962ea59a1e78b79c2a03fb8c5a7e0260865fb111e8238d/rio_cogeo-7.0.0.tar.gz", hash = "sha256:de1c8ff39e4f4357c73eec1cd86c0054fa2b2542898cfad2036921776660ab79", size = 19260, upload-time = "2025-11-21T18:02:08.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/10/cccc7eb6757bdca9ef2f25bcc10eb50dbc05646570308690c7165f075d5d/rio_cogeo-7.0.0-py3-none-any.whl", hash = "sha256:eac3e7b26ef8354ee299efbeea70202ccec257d41eaddc8f9ec9926d3ffb244b", size = 21808, upload-time = "2025-11-21T18:02:09.436Z" }, +] [[package]] name = "rio-stac" @@ -4813,13 +4816,13 @@ wheels = [ [[package]] name = "titiler" -version = "0.25.0" +version = "0.26.0" source = { virtual = "." } dependencies = [ { name = "titiler-application" }, { name = "titiler-core" }, { name = "titiler-extensions" }, - { name = "titiler-mosaic" }, + { name = "titiler-mosaic", extra = ["mosaicjson"] }, { name = "titiler-xarray" }, ] @@ -4872,7 +4875,7 @@ requires-dist = [ { name = "titiler-application", editable = "src/titiler/application" }, { name = "titiler-core", editable = "src/titiler/core" }, { name = "titiler-extensions", editable = "src/titiler/extensions" }, - { name = "titiler-mosaic", editable = "src/titiler/mosaic" }, + { name = "titiler-mosaic", extras = ["mosaicjson"], editable = "src/titiler/mosaic" }, { name = "titiler-xarray", editable = "src/titiler/xarray" }, ] @@ -4926,7 +4929,7 @@ dependencies = [ { name = "starlette-cramjam" }, { name = "titiler-core", extra = ["telemetry"] }, { name = "titiler-extensions", extra = ["cogeo", "stac"] }, - { name = "titiler-mosaic" }, + { name = "titiler-mosaic", extra = ["mosaicjson"] }, ] [package.optional-dependencies] @@ -4950,7 +4953,7 @@ requires-dist = [ { name = "starlette-cramjam", specifier = ">=0.4,<0.6" }, { name = "titiler-core", extras = ["telemetry"], editable = "src/titiler/core" }, { name = "titiler-extensions", extras = ["cogeo", "stac"], editable = "src/titiler/extensions" }, - { name = "titiler-mosaic", editable = "src/titiler/mosaic" }, + { name = "titiler-mosaic", extras = ["mosaicjson"], editable = "src/titiler/mosaic" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.12.0" }, ] provides-extras = ["server"] @@ -5071,10 +5074,14 @@ test = [ name = "titiler-mosaic" source = { editable = "src/titiler/mosaic" } dependencies = [ - { name = "cogeo-mosaic" }, { name = "titiler-core" }, ] +[package.optional-dependencies] +mosaicjson = [ + { name = "cogeo-mosaic" }, +] + [package.dev-dependencies] test = [ { name = "httpx" }, @@ -5085,9 +5092,10 @@ test = [ [package.metadata] requires-dist = [ - { name = "cogeo-mosaic", specifier = ">=9.0,<10.0" }, + { name = "cogeo-mosaic", marker = "extra == 'mosaicjson'", specifier = ">=9.0,<10.0" }, { name = "titiler-core", editable = "src/titiler/core" }, ] +provides-extras = ["mosaicjson"] [package.metadata.requires-dev] test = [