Skip to content

Commit 2821a9b

Browse files
add OGC Maps for Mosaics (#1283)
* add OGC Maps for Mosaics * update docs
1 parent 994203e commit 2821a9b

6 files changed

Lines changed: 622 additions & 266 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* remove default for `MosaicTilerFactory.backend` attribute **breaking change**
4646
* add `titiler.mosaic.extensions.mosaicjson.MosaicJSONExtension` which adds MosaicJSON specific `/` and `/validate` endpoints
4747
* add `titiler.mosaic.extension.wmts.wmtsExtension` which adds `/WMTSCapabilities.xml` endpoint
48+
* add optional OGC Maps API `/map` endpoint
4849

4950
## 0.26.0 (2025-11-25)
5051

docs/src/advanced/endpoints_factories.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ Endpoints factory for mosaics.
325325
- **add_viewer**: Add `/{TileMatrixSetId}/map.html` endpoints to the router. Defaults to `True`.
326326
- **add_statistics**: Add `POST - /statistics` endpoints to the router. Defaults to `False`.
327327
- **add_part**: Add `/bbox` and `/feature` endpoints to the router. Defaults to `False`.
328+
- **add_ogc_maps**: Add `/map` endpoints to the router. Default to `False`.
328329
- **conforms_to**: Set of conformance classes the Factory implement
329330

330331
#### Endpoints
@@ -345,7 +346,7 @@ Endpoints factory for mosaics.
345346
| `POST` | `/statistics` | GeoJSON ([Statistics][stats_geojson_model]) | return info and statistics for a dataset **Optional**
346347
| `GET` | `/bbox/{minx},{miny},{maxx},{maxy}[/{width}x{height}].{format}` | image/bin | create an image from part of a dataset **Optional**
347348
| `POST` | `/feature[/{width}x{height}][.{format}]` | image/bin | create an image from a geojson feature **Optional**
348-
349+
| `GET` | `/map` | image/bin | create maps from a dataset **Optional**
349350

350351
```python
351352
from fastapi import FastAPI
@@ -361,6 +362,7 @@ mosaic = TilerFactory(
361362
backend=MosaicJSONBackend,
362363
add_part=True, # default to False
363364
add_statistics=True, # default to False
365+
add_ogc_maps=True, # default to False
364366
)
365367

366368
# add router endpoint to the main application

src/titiler/mosaic/tests/test_factory.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from starlette.testclient import TestClient
2020

2121
from titiler.core.dependencies import DefaultDependency
22+
from titiler.core.errors import add_exception_handlers
2223
from titiler.core.resources.enums import OptionalHeader
24+
from titiler.mosaic.errors import MOSAIC_STATUS_CODES
2325
from titiler.mosaic.extensions.mosaicjson import MosaicJSONExtension
2426
from titiler.mosaic.extensions.wmts import wmtsExtension
2527
from titiler.mosaic.factory import MosaicTilerFactory
@@ -608,3 +610,189 @@ def test_MosaicTilerFactory_asset_accessor():
608610
)
609611
assert response.status_code == 200
610612
assert len(response.json()) == 1
613+
614+
615+
def test_ogc_maps_mosaic():
616+
"""Test TilerFactory class."""
617+
mosaic = MosaicTilerFactory(
618+
backend=MosaicJSONBackend,
619+
add_ogc_maps=True,
620+
)
621+
app = FastAPI()
622+
app.include_router(mosaic.router)
623+
add_exception_handlers(app, MOSAIC_STATUS_CODES)
624+
625+
assert (
626+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core" in mosaic.conforms_to
627+
)
628+
629+
with TestClient(app) as client:
630+
with tmpmosaic() as mosaic_file:
631+
# Conformance Class “Core”
632+
response = client.get("/map", params={"url": mosaic_file})
633+
assert response.status_code == 501
634+
635+
# Conformance Class “Spatial Subsetting”
636+
# /conf/spatial-subsetting/bbox-crs
637+
response = client.get(
638+
"/map",
639+
params={
640+
"url": mosaic_file,
641+
"bbox": "-74,45,-73,46",
642+
},
643+
)
644+
headers = response.headers
645+
# Default CRS for Mosaic is EPSG:4326
646+
assert (
647+
headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/4326>"
648+
)
649+
assert headers["Content-Bbox"] == "-74.0,45.0,-73.0,46.0"
650+
assert headers["content-type"] == "image/png"
651+
652+
response = client.get(
653+
"/map",
654+
params={
655+
"url": mosaic_file,
656+
# tile 9-151-184
657+
"bbox": "-8218509.281222152,5557277.704445455,-8140237.7642581295,5635549.221409475",
658+
"bbox-crs": "http://www.opengis.net/def/crs/EPSG/0/3857",
659+
},
660+
)
661+
headers = response.headers
662+
assert headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/4326>"
663+
assert (
664+
headers["Content-Bbox"]
665+
== "-73.828125,44.590467181308846,-73.125,45.08903556483102"
666+
)
667+
assert headers["content-type"] == "image/png"
668+
669+
response = client.get(
670+
"/map",
671+
params={
672+
"url": mosaic_file,
673+
# tile 9-151-184
674+
"bbox": "-8218509.281222152,5557277.704445455,-8140237.7642581295,5635549.221409475",
675+
"bbox-crs": "[EPSG:3857]",
676+
},
677+
)
678+
headers = response.headers
679+
assert headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/4326>"
680+
assert (
681+
headers["Content-Bbox"]
682+
== "-73.828125,44.590467181308846,-73.125,45.08903556483102"
683+
)
684+
assert headers["content-type"] == "image/png"
685+
686+
response = client.get(
687+
"/map",
688+
params={
689+
"url": mosaic_file,
690+
# tile 9-151-184
691+
"bbox": "-8218509.281222152,5557277.704445455,-8140237.7642581295,5635549.221409475",
692+
"bbox-crs": "[EPSG:3857]",
693+
"crs": "[EPSG:3857]",
694+
},
695+
)
696+
assert response.status_code == 200
697+
headers = response.headers
698+
assert headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/3857>"
699+
assert (
700+
headers["Content-Bbox"]
701+
== "-8218509.281222152,5557277.704445455,-8140237.7642581295,5635549.221409475"
702+
)
703+
assert headers["content-type"] == "image/png"
704+
705+
response = client.get(
706+
"/map",
707+
params={
708+
"url": mosaic_file,
709+
# tile 4-4-5
710+
"bbox": "-10018754.171394622,5009377.085697312,-7514065.628545966,7514065.628545966",
711+
"bbox-crs": "[EPSG:3857]",
712+
"crs": "[EPSG:3857]",
713+
},
714+
)
715+
assert response.status_code == 200
716+
headers = response.headers
717+
assert headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/3857>"
718+
assert (
719+
headers["Content-Bbox"]
720+
== "-10018754.171394622,5009377.085697312,-7514065.628545966,7514065.628545966"
721+
)
722+
assert headers["content-type"] == "image/png"
723+
meta = parse_img(response.content)
724+
assert meta["width"] == 1024 # default max size
725+
assert meta["height"] == 1024
726+
727+
response = client.get(
728+
"/map",
729+
params={
730+
"url": mosaic_file,
731+
"bbox": "-74,45,-73,46",
732+
},
733+
headers={"Accept": "image/jpeg"},
734+
)
735+
headers = response.headers
736+
assert headers["content-type"] == "image/jpeg"
737+
738+
response = client.get(
739+
"/map",
740+
params={
741+
"url": mosaic_file,
742+
"bbox": "-74,45,-73,46",
743+
"f": "tiff",
744+
},
745+
)
746+
headers = response.headers
747+
assert headers["content-type"] == "image/tiff; application=geotiff"
748+
749+
# Conformance Class “Scaling”
750+
response = client.get(
751+
"/map",
752+
params={
753+
"url": mosaic_file,
754+
"bbox": "-74,45,-73,46",
755+
"width": 256,
756+
},
757+
)
758+
assert response.status_code == 200
759+
headers = response.headers
760+
assert headers["content-type"] == "image/png"
761+
meta = parse_img(response.content)
762+
assert meta["width"] == 256
763+
assert meta["height"] == 256
764+
765+
response = client.get(
766+
"/map",
767+
params={
768+
"url": mosaic_file,
769+
"bbox": "-74,45,-73,46",
770+
"width": -256,
771+
},
772+
)
773+
assert response.status_code == 422
774+
775+
# /req/scaling/height-definition
776+
response = client.get(
777+
"/map",
778+
params={
779+
"url": mosaic_file,
780+
"bbox": "-74,45,-73,46",
781+
"height": 256,
782+
},
783+
)
784+
assert response.status_code == 200
785+
headers = response.headers
786+
assert headers["content-type"] == "image/png"
787+
meta = parse_img(response.content)
788+
assert meta["width"] == 256
789+
assert meta["height"] == 256
790+
791+
response = client.get(
792+
"/map",
793+
params={
794+
"url": mosaic_file,
795+
"height": -256,
796+
},
797+
)
798+
assert response.status_code == 422

src/titiler/mosaic/titiler/mosaic/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
MOSAIC_STATUS_CODES = {
77
EmptyMosaicError: status.HTTP_204_NO_CONTENT,
88
NoAssetFoundError: status.HTTP_204_NO_CONTENT,
9+
NotImplementedError: status.HTTP_501_NOT_IMPLEMENTED,
910
}

src/titiler/mosaic/titiler/mosaic/factory.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
DstCRSParams,
4949
HistogramParams,
5050
ImageRenderingParams,
51+
OGCMapsParams,
5152
PartFeatureParams,
5253
StatisticsParams,
5354
TileParams,
@@ -152,6 +153,7 @@ class MosaicTilerFactory(BaseFactory):
152153
add_viewer: bool = True
153154
add_statistics: bool = False
154155
add_part: bool = False
156+
add_ogc_maps: bool = False
155157

156158
conforms_to: Set[str] = field(
157159
factory=lambda: {
@@ -185,6 +187,9 @@ def register_routes(self):
185187
if self.add_statistics:
186188
self.statistics()
187189

190+
if self.add_ogc_maps:
191+
self.ogc_maps()
192+
188193
############################################################################
189194
# /info
190195
############################################################################
@@ -1346,3 +1351,115 @@ def assets_for_tile(
13461351
z,
13471352
**assets_accessor_params.as_dict(),
13481353
)
1354+
1355+
############################################################################
1356+
# OGC Maps (Optional)
1357+
############################################################################
1358+
def ogc_maps(self): # noqa: C901
1359+
"""Register OGC Maps /map` endpoint."""
1360+
1361+
self.conforms_to.update(
1362+
{
1363+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core",
1364+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/crs",
1365+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/scaling",
1366+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/scaling/width-definition",
1367+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/scaling/height-definition",
1368+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/spatial-subsetting",
1369+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/spatial-subsetting/bbox-definition",
1370+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/spatial-subsetting/bbox-crs",
1371+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/spatial-subsetting/crs-curie",
1372+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/png",
1373+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/jpeg",
1374+
"https://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/tiff",
1375+
}
1376+
)
1377+
1378+
@self.router.get(
1379+
"/map",
1380+
operation_id=f"{self.operation_prefix}getMap",
1381+
**img_endpoint_params,
1382+
)
1383+
def get_map(
1384+
src_path=Depends(self.path_dependency),
1385+
ogc_params=Depends(OGCMapsParams),
1386+
backend_params=Depends(self.backend_dependency),
1387+
reader_params=Depends(self.reader_dependency),
1388+
assets_accessor_params=Depends(self.assets_accessor_dependency),
1389+
layer_params=Depends(self.layer_dependency),
1390+
dataset_params=Depends(self.dataset_dependency),
1391+
pixel_selection=Depends(self.pixel_selection_dependency),
1392+
post_process=Depends(self.process_dependency),
1393+
colormap=Depends(self.colormap_dependency),
1394+
render_params=Depends(self.render_dependency),
1395+
env=Depends(self.environment_dependency),
1396+
):
1397+
"""OGC Maps API."""
1398+
with rasterio.Env(**env):
1399+
logger.info(
1400+
f"opening data with backend: {self.backend} and reader {self.dataset_reader}"
1401+
)
1402+
with self.backend(
1403+
src_path,
1404+
reader=self.dataset_reader,
1405+
reader_options=reader_params.as_dict(),
1406+
**backend_params.as_dict(),
1407+
) as src_dst:
1408+
if ogc_params.bbox is not None:
1409+
image, assets = src_dst.part(
1410+
ogc_params.bbox,
1411+
dst_crs=ogc_params.crs or src_dst.crs,
1412+
bounds_crs=ogc_params.bbox_crs or WGS84_CRS,
1413+
search_options=assets_accessor_params.as_dict(),
1414+
pixel_selection=pixel_selection,
1415+
threads=MOSAIC_THREADS,
1416+
width=ogc_params.width,
1417+
height=ogc_params.height,
1418+
max_size=ogc_params.max_size,
1419+
**layer_params.as_dict(),
1420+
**dataset_params.as_dict(),
1421+
)
1422+
1423+
else:
1424+
# NOTE: Defaults backends do not support preview
1425+
image, assets = src_dst.preview(
1426+
search_options=assets_accessor_params.as_dict(),
1427+
pixel_selection=pixel_selection,
1428+
threads=MOSAIC_THREADS,
1429+
width=ogc_params.width,
1430+
height=ogc_params.height,
1431+
max_size=ogc_params.max_size,
1432+
dst_crs=ogc_params.crs or src_dst.crs,
1433+
**layer_params.as_dict(),
1434+
**dataset_params.as_dict(),
1435+
)
1436+
dst_colormap = getattr(src_dst, "colormap", None)
1437+
1438+
if post_process:
1439+
image = post_process(image)
1440+
1441+
content, media_type = self.render_func(
1442+
image,
1443+
output_format=ogc_params.format,
1444+
colormap=colormap or dst_colormap,
1445+
**render_params.as_dict(),
1446+
)
1447+
1448+
headers: Dict[str, str] = {}
1449+
if OptionalHeader.x_assets in self.optional_headers:
1450+
headers["X-Assets"] = ",".join(assets)
1451+
1452+
if image.bounds is not None:
1453+
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
1454+
if uri := CRS_to_uri(image.crs):
1455+
headers["Content-Crs"] = f"<{uri}>"
1456+
1457+
if (
1458+
OptionalHeader.server_timing in self.optional_headers
1459+
and image.metadata.get("timings")
1460+
):
1461+
headers["Server-Timing"] = ", ".join(
1462+
[f"{name};dur={time}" for (name, time) in image.metadata["timings"]]
1463+
)
1464+
1465+
return Response(content, media_type=media_type, headers=headers)

0 commit comments

Comments
 (0)