From c1cb9effd0a0d572e9f3f27c36cd1a9960db2f8a Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 12 Jun 2026 17:35:42 +0200 Subject: [PATCH 1/3] feat: extend tilejson and add get-renders method to tilerFactories --- .../application/titiler/application/main.py | 1 + src/titiler/core/titiler/core/factory.py | 20 +++++++++++- .../core/titiler/core/models/mapbox.py | 10 ++++++ .../extensions/tests/test_stac_render.py | 4 ++- src/titiler/extensions/tests/test_wmts.py | 31 +++++++++---------- .../extensions/titiler/extensions/render.py | 4 +-- .../extensions/titiler/extensions/wmts.py | 21 +++++++++++-- src/titiler/mosaic/tests/test_factory.py | 31 +++++++++---------- .../mosaic/titiler/mosaic/extensions/wmts.py | 20 ++++++++++-- src/titiler/mosaic/titiler/mosaic/factory.py | 28 +++++++++-------- src/titiler/xarray/titiler/xarray/factory.py | 4 +-- 11 files changed, 115 insertions(+), 59 deletions(-) diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index 368253b73..9a2733451 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -152,6 +152,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): reader=STACReader, router_prefix="/stac", add_ogc_maps=True, + get_renders=lambda obj: obj.item.properties.get("renders", {}), extensions=[stacViewerExtension(), stacRenderExtension(), wmtsExtension()], enable_telemetry=api_settings.telemetry_enabled, templates=titiler_templates, diff --git a/src/titiler/core/titiler/core/factory.py b/src/titiler/core/titiler/core/factory.py index 7cea4c0f8..a6bb3989b 100644 --- a/src/titiler/core/titiler/core/factory.py +++ b/src/titiler/core/titiler/core/factory.py @@ -329,6 +329,10 @@ class TilerFactory(BaseFactory): render_func: Callable[..., tuple[bytes, str]] = render_image + get_renders: Callable[[BaseReader], dict[str, dict[str, Any]]] = field( + default=lambda obj: {} + ) + # Add/Remove some endpoints add_preview: bool = True add_part: bool = True @@ -1015,7 +1019,7 @@ def tilejson( with self.reader( src_path, tms=tms, **reader_params.as_dict() ) as src_dst: - return { + body = { "bounds": src_dst.get_geographic_bounds( tms.rasterio_geographic_crs ), @@ -1025,6 +1029,16 @@ def tilejson( "attribution": os.environ.get("TITILER_DEFAULT_ATTRIBUTION"), } + # Custom TiTiler tilejson fields + body["raster_layers"] = self.get_renders(src_dst) + + info = src_dst.info() + body["band_descriptions"] = getattr(info, "band_descriptions", None) + body["data_type"] = getattr(info, "dtype", None) + body["minmax"] = getattr(info, "minmax", None) + + return body + def map_viewer(self): # noqa: C901 """Register /map.html endpoint.""" @@ -1472,6 +1486,10 @@ class MultiBaseTilerFactory(TilerFactory): # Assets dependency assets_dependency: type[DefaultDependency] = AssetsParams + get_renders: Callable[[MultiBaseReader], dict[str, dict[str, Any]]] = field( + default=lambda obj: {} + ) + # Overwrite the `/info` endpoint to return the list of assets when no assets is passed. def info(self): """Register /info endpoint.""" diff --git a/src/titiler/core/titiler/core/models/mapbox.py b/src/titiler/core/titiler/core/models/mapbox.py index 1cdf3d2e7..a87046a2d 100644 --- a/src/titiler/core/titiler/core/models/mapbox.py +++ b/src/titiler/core/titiler/core/models/mapbox.py @@ -54,3 +54,13 @@ def compute_center(self): self.minzoom, ) return self + + +class TiTilerJSON(TileJSON): + """TiTiler custom TileJSON model.""" + + raster_layers: list[dict] | None = None + + band_descriptions: list[tuple[str, str]] | None = None + dtype: str | None = None + minmax: list[tuple[float, float]] | None = None diff --git a/src/titiler/extensions/tests/test_stac_render.py b/src/titiler/extensions/tests/test_stac_render.py index 16d6487a9..72cf378a2 100644 --- a/src/titiler/extensions/tests/test_stac_render.py +++ b/src/titiler/extensions/tests/test_stac_render.py @@ -20,7 +20,9 @@ def test_stacExtension(): stac_tiler = MultiBaseTilerFactory(reader=STACReader) stac_tiler_plus_stac_render = MultiBaseTilerFactory( - reader=STACReader, extensions=[stacRenderExtension(), wmtsExtension()] + reader=STACReader, + extensions=[stacRenderExtension(), wmtsExtension()], + get_renders=lambda obj: obj.item.properties.get("renders", {}), ) # Check that we added two routes (/renders & /renders/{render_id}) and `/WMTSCapabilities.xml` assert ( diff --git a/src/titiler/extensions/tests/test_wmts.py b/src/titiler/extensions/tests/test_wmts.py index a649d76f4..ebe7a8f39 100644 --- a/src/titiler/extensions/tests/test_wmts.py +++ b/src/titiler/extensions/tests/test_wmts.py @@ -118,24 +118,21 @@ def test_wmtsExtension(): def test_wmtsExtension_with_renders(): """Test wmtsExtension class with Renders.""" tiler_plus_wmts = TilerFactory( - extensions=[ - wmtsExtension( - get_renders=lambda obj: { - "one_band_limit": { - "tilematrixsets": { - "WebMercatorQuad": [0, 1], - }, - "spatial_extent": (-180, -90, 180, 90), - "bidx": [1, 2, 3], - "rescale": [[0, 10000], [0, 5000], [0, 1000]], - }, - "one_band": { - "bidx": [1], - "rescale": ["0,10000"], - }, + get_renders=lambda obj: { + "one_band_limit": { + "tilematrixsets": { + "WebMercatorQuad": [0, 1], }, - ) - ], + "spatial_extent": (-180, -90, 180, 90), + "bidx": [1, 2, 3], + "rescale": [[0, 10000], [0, 5000], [0, 1000]], + }, + "one_band": { + "bidx": [1], + "rescale": ["0,10000"], + }, + }, + extensions=[wmtsExtension()], ) app = FastAPI() diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py index 8d1c342a3..da5060c00 100644 --- a/src/titiler/extensions/titiler/extensions/render.py +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -168,7 +168,7 @@ def _prepare_render_item( ) def render_list(request: Request, src_path=Depends(factory.path_dependency)): with factory.reader(src_path) as src: - renders = src.item.properties.get("renders", {}) + renders = factory.get_renders(src) prepared_renders = { render_id: _prepare_render_item(render_id, render, request, src_path) @@ -201,7 +201,7 @@ def render( src_path=Depends(factory.path_dependency), ): with factory.reader(src_path) as src: - renders = src.item.properties.get("renders", {}) + renders = factory.get_renders(src) if render_id not in renders: raise HTTPException(status_code=404, detail="Render not found") diff --git a/src/titiler/extensions/titiler/extensions/wmts.py b/src/titiler/extensions/titiler/extensions/wmts.py index d84805a47..a968ee2f5 100644 --- a/src/titiler/extensions/titiler/extensions/wmts.py +++ b/src/titiler/extensions/titiler/extensions/wmts.py @@ -45,16 +45,31 @@ class wmtsExtension(FactoryExtension): templates: Jinja2Templates = field(default=DEFAULT_TEMPLATES) - get_renders: Callable[[BaseReader], dict[str, dict[str, Any]]] = field( - default=lambda obj: {} + # TODO: Remove in 3.0 + get_renders: Callable[[BaseReader], dict[str, dict[str, Any]]] | None = field( + default=None ) # List of dependencies a `/tile` URL should validate # Note: Those dependencies should only require Query() inputs tile_dependencies: list[Callable] | None = field(default=None) + # TODO: Remove in 3.0 + def __attrs_post_init__(self): + """Warn about deprecation of `get_renders` attribute.""" + if self.get_renders: + warnings.warn( + "The wmtsExtension's `get_renders` attribute is deprecated and will be ignored. Please set it at the factory level.", + DeprecationWarning, + stacklevel=2, + ) + def register(self, factory: TilerFactory): # type: ignore [override] # noqa: C901 """Register extension's endpoints.""" + + # TODO: Remove in 3.0 + self.get_renders = factory.get_renders + tile_dependencies = ( self.tile_dependencies if self.tile_dependencies is not None @@ -105,7 +120,7 @@ def wmts( # noqa: C901 with rasterio.Env(**env): with factory.reader(src_path, **reader_params.as_dict()) as src_dst: dataset_bounds = src_dst.get_geographic_bounds(self.crs) - default_renders = self.get_renders(src_dst) + default_renders = factory.get_renders(src_dst) renders: list[dict[str, Any]] = [] diff --git a/src/titiler/mosaic/tests/test_factory.py b/src/titiler/mosaic/tests/test_factory.py index 24d500639..c0e2952b2 100644 --- a/src/titiler/mosaic/tests/test_factory.py +++ b/src/titiler/mosaic/tests/test_factory.py @@ -813,24 +813,21 @@ def test_wmts_extension_mosaic(): """Test wmtsExtension extension.""" mosaic = MosaicTilerFactory( backend=MosaicJSONBackend, - extensions=[ - wmtsExtension( - get_renders=lambda obj: { - "one_band_limit": { - "tilematrixsets": { - "WebMercatorQuad": [0, 1], - }, - "spatial_extent": (-180, -90, 180, 90), - "bidx": [1, 2, 3], - "rescale": [[0, 10000], [0, 5000], [0, 1000]], - }, - "one_band": { - "bidx": [1], - "rescale": ["0,10000"], - }, + get_renders=lambda obj: { + "one_band_limit": { + "tilematrixsets": { + "WebMercatorQuad": [0, 1], }, - ) - ], + "spatial_extent": (-180, -90, 180, 90), + "bidx": [1, 2, 3], + "rescale": [[0, 10000], [0, 5000], [0, 1000]], + }, + "one_band": { + "bidx": [1], + "rescale": ["0,10000"], + }, + }, + extensions=[wmtsExtension()], ) app = FastAPI() app.include_router(mosaic.router) diff --git a/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py b/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py index 63f4d4465..43639fc44 100644 --- a/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py +++ b/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py @@ -46,17 +46,31 @@ class wmtsExtension(FactoryExtension): templates: Jinja2Templates = field(default=DEFAULT_TEMPLATES) - get_renders: Callable[[BaseBackend], dict[str, dict[str, Any]]] = field( - default=lambda obj: {} + # TODO: Remove in 3.0 + get_renders: Callable[[BaseBackend], dict[str, dict[str, Any]]] | None = field( + default=None ) # List of dependencies a `/tile` URL should validate # Note: Those dependencies should only require Query() inputs tile_dependencies: list[Callable] | None = field(default=None) + # TODO: Remove in 3.0 + def __attrs_post_init__(self): + """Warn about deprecation of `get_renders` attribute.""" + if self.get_renders: + warnings.warn( + "The wmtsExtension's `get_renders` attribute is deprecated and will be ignored. Please set it at the factory level.", + DeprecationWarning, + stacklevel=2, + ) + def register(self, factory: MosaicTilerFactory): # type: ignore [override] # noqa: C901 """Register endpoint to the tiler factory.""" + # TODO: Remove in 3.0 + self.get_renders = factory.get_renders + tile_dependencies = ( self.tile_dependencies if self.tile_dependencies is not None @@ -116,7 +130,7 @@ def wmts( # noqa: C901 **backend_params.as_dict(), ) as src_dst: dataset_bounds = src_dst.get_geographic_bounds(self.crs) - default_renders = self.get_renders(src_dst) + default_renders = factory.get_renders(src_dst) renders: list[dict[str, Any]] = [] diff --git a/src/titiler/mosaic/titiler/mosaic/factory.py b/src/titiler/mosaic/titiler/mosaic/factory.py index 56e30422c..bdfdfa8d6 100644 --- a/src/titiler/mosaic/titiler/mosaic/factory.py +++ b/src/titiler/mosaic/titiler/mosaic/factory.py @@ -134,6 +134,10 @@ class MosaicTilerFactory(BaseFactory): render_func: Callable[..., tuple[bytes, str]] = render_image + get_renders: Callable[[BaseBackend], dict[str, dict[str, Any]]] = field( + default=lambda obj: {} + ) + optional_headers: list[OptionalHeader] = field(factory=list) # Add/Remove some endpoints @@ -769,23 +773,21 @@ def tilejson( reader_options=reader_params.as_dict(), **backend_params.as_dict(), ) as src_dst: - bounds = src_dst.get_geographic_bounds(tms.rasterio_geographic_crs) - minzoom = minzoom if minzoom is not None else src_dst.minzoom - maxzoom = maxzoom if maxzoom is not None else src_dst.maxzoom - center = ( - (bounds[0] + bounds[2]) / 2, - (bounds[1] + bounds[3]) / 2, - minzoom, - ) - return { - "bounds": bounds, - "center": center, - "minzoom": minzoom, - "maxzoom": maxzoom, + body = { + "bounds": src_dst.get_geographic_bounds( + tms.rasterio_geographic_crs + ), + "minzoom": minzoom if minzoom is not None else src_dst.minzoom, + "maxzoom": maxzoom if maxzoom is not None else src_dst.maxzoom, "tiles": [tiles_url], "attribution": os.environ.get("TITILER_DEFAULT_ATTRIBUTION"), } + # Custom TiTiler tilejson fields + body["raster_layers"] = self.get_renders(src_dst) + + return body + def map_viewer(self): # noqa: C901 """Register /map.html endpoint.""" diff --git a/src/titiler/xarray/titiler/xarray/factory.py b/src/titiler/xarray/titiler/xarray/factory.py index 4a20073dd..aeb329794 100644 --- a/src/titiler/xarray/titiler/xarray/factory.py +++ b/src/titiler/xarray/titiler/xarray/factory.py @@ -105,7 +105,7 @@ def info_endpoint( Query(description="Show info about the time dimension"), ] = None, env=Depends(self.environment_dependency), - ) -> Info: + ): """Return dataset's basic info.""" with rasterio.Env(**env): logger.info(f"opening data with reader: {self.reader}") @@ -116,7 +116,7 @@ def info_endpoint( info["count"] = len(times) info["times"] = times - return Info(**info) + return info @self.router.get( "/info.geojson", From b00567cc95506e8a7c71ba817771ba46fa07e00e Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 12 Jun 2026 18:05:49 +0200 Subject: [PATCH 2/3] fix: type --- src/titiler/application/titiler/application/main.py | 2 +- src/titiler/core/titiler/core/factory.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index 9a2733451..175eb9f65 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -152,7 +152,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): reader=STACReader, router_prefix="/stac", add_ogc_maps=True, - get_renders=lambda obj: obj.item.properties.get("renders", {}), + get_renders=lambda obj: obj.item.properties.get("renders", {}), # type: ignore extensions=[stacViewerExtension(), stacRenderExtension(), wmtsExtension()], enable_telemetry=api_settings.telemetry_enabled, templates=titiler_templates, diff --git a/src/titiler/core/titiler/core/factory.py b/src/titiler/core/titiler/core/factory.py index a6bb3989b..d5ae3a6ad 100644 --- a/src/titiler/core/titiler/core/factory.py +++ b/src/titiler/core/titiler/core/factory.py @@ -1486,7 +1486,7 @@ class MultiBaseTilerFactory(TilerFactory): # Assets dependency assets_dependency: type[DefaultDependency] = AssetsParams - get_renders: Callable[[MultiBaseReader], dict[str, dict[str, Any]]] = field( + get_renders: Callable[[MultiBaseReader], dict[str, dict[str, Any]]] = field( # type: ignore default=lambda obj: {} ) From 6ac8aa3f7a3f3145cf999ca7e416ab14cc457846 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 3 Jul 2026 15:03:50 +0200 Subject: [PATCH 3/3] fix: type --- src/titiler/core/titiler/core/algorithm/dem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/titiler/core/titiler/core/algorithm/dem.py b/src/titiler/core/titiler/core/algorithm/dem.py index ce58c8d6c..23f5f90c0 100644 --- a/src/titiler/core/titiler/core/algorithm/dem.py +++ b/src/titiler/core/titiler/core/algorithm/dem.py @@ -179,7 +179,7 @@ def __call__(self, img: ImageData) -> ImageData: """Encode DEM into RGB.""" data = numpy.clip(img.array[0] + 32768.0, 0.0, 65535.0) if self.nodata_height is not None: - data[img.array.mask[0]] = numpy.clip( # type: ignore [index] + data[img.array.mask[0]] = numpy.clip( # type: ignore [call-overload] self.nodata_height + 32768.0, 0.0, 65535.0 ) r = data / 256 @@ -239,7 +239,7 @@ def _range_check(datarange): raise ValueError(f"Data of {datarange} larger than 256 ** 3") if self.nodata_height is not None: - data[img.array.mask[0]] = ( # type: ignore [index] + data[img.array.mask[0]] = ( # type: ignore [call-overload] self.nodata_height - self.baseval ) / self.interval