Coverage for gws-app/gws/plugin/ows_server/wmts/__init__.py: 53%
114 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""WMTS service.
3Implements WMTS 1.0.0 with the operations GetCapabilities, GetTile and
4GetLegendGraphic. Only ``GET`` requests with ``KVP`` encoding are supported.
6The service publishes the project layers that are not groups and can render
7a box. For each supported CRS of the service, it offers one tile matrix set
8named ``TMS_<srid>``, with levels 0 to ``MAX_LEVEL``; the matrix identifiers
9are the zero-padded level numbers (``00``, ``01``, ...). The tile matrix sets
10are based on map grids: the grid configured in ``grids`` for the CRS, or the
11default grid for the CRS. All layers share the same tile matrix sets.
13GetTile renders the requested layer into a tile of the grid size. With the
14developer option ``ows.annotate_wmts``, the tile is annotated with its
15matrix, row, column and extent.
17Templates:
19- ``templates/getCapabilities.cx.py``: ``ows.GetCapabilities``.
21References:
23- OGC 07-057r7 (https://portal.ogc.org/files/?artifact_id=35326)
25Example::
27 projects+ {
28 owsServices+ {
29 type "wmts"
30 uid "my_wmts"
31 supportedCrs [3857 25832]
32 grids+ {
33 crs 25832
34 extent [280000 5200000 920000 6100000]
35 }
36 }
37 }
38"""
40from typing import Optional
42import gws
43import gws.config.util
44import gws.base.ows.server as server
45import gws.lib.crs
46import gws.lib.grid
47import gws.lib.mime
48import gws.gis.render
49import gws.gis.zoom
52MAX_LEVEL = 20
53"""Finest advertised tile matrix level."""
56@gws.ext.config.owsService('wmts')
57class Config(server.service.Config):
58 """WMTS service that serves the project layers as tiles."""
60 grids: Optional[list[gws.lib.grid.Config]]
61 """Tile matrix grids, one per CRS. (added in 8.5)"""
64_DEFAULT_TEMPLATES = [
65 gws.Config(
66 type='py',
67 path=gws.u.dirname(__file__) + '/templates/getCapabilities.cx.py',
68 subject='ows.GetCapabilities',
69 mimeTypes=[gws.lib.mime.XML],
70 access=gws.c.PUBLIC,
71 ),
72]
74_DEFAULT_METADATA = gws.Metadata(
75 inspireDegreeOfConformity='notEvaluated',
76 inspireMandatoryKeyword='infoMapAccessService',
77 inspireResourceType='service',
78 inspireSpatialDataServiceType='view',
79 isoScope='dataset',
80 isoSpatialRepresentationType='vector',
81)
84@gws.ext.object.owsService('wmts')
85class Object(server.service.Object):
86 """WMTS service that renders the project layers as tiles."""
88 protocol = gws.OwsProtocol.WMTS
89 supportedVersions = ['1.0.0']
90 isRasterService = True
91 isOwsCommon = True
93 tileMatrixSets: list[gws.TileMatrixSet]
94 """Tile matrix sets, one for each supported CRS."""
95 grids: dict[str, gws.MapGrid]
96 """Grids by tile matrix set identifier."""
98 def configure(self):
99 gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
101 configured = {}
102 for p in self.cfg('grids', default=[]):
103 if not p.crs:
104 raise gws.ConfigurationError('wmts grid: crs is required')
105 crs = gws.lib.crs.require(p.crs)
106 if crs.srid in configured:
107 raise gws.ConfigurationError(f'wmts grid: duplicate crs {crs.srid}')
108 configured[crs.srid] = gws.lib.grid.new(
109 gws.lib.grid.Options(
110 crs=crs,
111 extent=p.extent,
112 baseResolution=p.baseResolution,
113 tileSize=p.tileSize,
114 withSnap=p.withSnap,
115 )
116 )
118 supported = {b.crs.srid for b in self.supportedBounds}
119 for srid in configured:
120 if srid not in supported:
121 raise gws.ConfigurationError(f'wmts grid: crs {srid} is not supported by the service')
123 # @TODO different matrix sets per layer
124 self.tileMatrixSets = []
125 self.grids = {}
126 for b in self.supportedBounds:
127 # see https://docs.opengeospatial.org/is/13-082r2/13-082r2.html#29
128 mg = configured.get(b.crs.srid) or gws.lib.grid.for_crs(b.crs)
129 ident = f'TMS_{b.crs.srid}'
130 self.grids[ident] = mg
131 self.tileMatrixSets.append(
132 gws.TileMatrixSet(
133 identifier=ident,
134 crs=b.crs,
135 matrices=self.make_tile_matrices(mg, 0, MAX_LEVEL),
136 )
137 )
139 def configure_operations(self):
140 self.supportedOperations = [
141 gws.OwsOperation(
142 verb=gws.OwsVerb.GetCapabilities,
143 formats=self.available_formats(gws.OwsVerb.GetCapabilities),
144 handlerName='handle_get_capabilities',
145 ),
146 gws.OwsOperation(
147 verb=gws.OwsVerb.GetLegendGraphic,
148 formats=self.available_formats(gws.OwsVerb.GetLegendGraphic),
149 handlerName='handle_get_legend_graphic',
150 ),
151 gws.OwsOperation(
152 verb=gws.OwsVerb.GetTile,
153 formats=self.available_formats(gws.OwsVerb.GetTile),
154 handlerName='handle_get_tile',
155 ),
156 ]
158 def make_tile_matrices(self, mg: gws.MapGrid, min_zoom, max_zoom):
159 """Create the tile matrices for a range of grid levels.
161 Args:
162 mg: Map grid.
163 min_zoom: First level.
164 max_zoom: Last level, inclusive.
166 Returns:
167 A list of tile matrices, one for each level.
168 """
169 ms = []
171 for z in range(min_zoom, max_zoom + 1):
172 nx, ny = gws.lib.grid.tile_count_for_level(mg, z)
173 res = gws.lib.grid.resolution_for_level(mg, z)
174 ms.append(
175 gws.TileMatrix(
176 identifier=f'{z:02d}',
177 scale=gws.gis.zoom.res_to_scale(res, mg.crs),
178 resolution=res,
179 x=mg.extent[0],
180 y=mg.extent[3],
181 tileWidth=mg.tileSize,
182 tileHeight=mg.tileSize,
183 width=nx,
184 height=ny,
185 extent=mg.extent,
186 )
187 )
189 return ms
191 ##
193 def init_request(self, req):
194 sr = super().init_request(req)
195 sr.require_project()
196 return sr
198 def layer_is_compatible(self, layer: gws.Layer):
199 return not layer.isGroup and layer.canRenderBox
201 ##
203 def handle_get_capabilities(self, sr: server.request.Object):
204 """Handle the GetCapabilities operation.
206 Args:
207 sr: Service request.
209 Returns:
210 The capabilities document.
211 """
212 return self.template_response(
213 sr,
214 sr.requested_format('FORMAT'),
215 layerCapsList=sr.layerCapsList,
216 tileMatrixSets=self.tileMatrixSets,
217 )
219 def handle_get_tile(self, sr: server.request.Object):
220 """Handle the GetTile operation.
222 Args:
223 sr: Service request.
225 Returns:
226 The tile image.
228 Raises:
229 ``server.error.InvalidParameterValue``: If not exactly one layer is requested.
230 ``server.error.TileOutOfRange``: If the tile matrix set or the tile matrix is unknown.
231 """
232 lcs = self.requested_layer_caps(sr)
233 if len(lcs) != 1:
234 raise server.error.InvalidParameterValue('LAYER')
236 tms_uid = sr.string_param('TILEMATRIXSET')
237 tm_uid = sr.string_param('TILEMATRIX')
238 row = sr.int_param('TILEROW')
239 col = sr.int_param('TILECOL')
241 bounds = self.bounds_for_tile(tms_uid, tm_uid, row, col)
242 if not bounds:
243 raise server.error.TileOutOfRange()
244 gws.log.debug(f'WMTS: bounds for tile {tms_uid=} {tm_uid=} {row=} {col=}: {bounds}')
246 mime_type = sr.requested_format('FORMAT')
247 ts = self.grids[tms_uid].tileSize
249 mri = gws.MapRenderInput(
250 backgroundColor=None,
251 bbox=bounds.extent,
252 targetCrs=bounds.crs,
253 mapSize=(ts, ts, gws.Uom.px),
254 planes=[
255 gws.MapRenderInputPlane(
256 type=gws.MapRenderInputPlaneType.imageLayer,
257 layer=lc.layer,
258 )
259 for lc in lcs
260 ],
261 )
263 mro = gws.gis.render.render_map(mri)
265 if self.root.app.developer_option('ows.annotate_wmts'):
266 e = bounds.extent
267 text = f'{tm_uid} {row} {col}\n{e[0]}\n{e[1]}\n{e[2]}\n{e[3]}'
268 mro.planes[0].image = mro.planes[0].image.add_text(text, x=10, y=10).add_box()
270 return self.image_response(sr, mro.planes[0].image, mime_type)
272 def handle_get_legend_graphic(self, sr: server.request.Object):
273 """Handle the GetLegendGraphic operation.
275 Args:
276 sr: Service request.
278 Returns:
279 The legend image of the requested layers.
280 """
281 lcs = self.requested_layer_caps(sr)
282 return self.render_legend(sr, lcs, sr.requested_format('FORMAT'))
284 ##
286 def requested_layer_caps(self, sr: server.request.Object):
287 """Find the layer caps for the layer names in the ``LAYER`` parameter.
289 Args:
290 sr: Service request.
292 Returns:
293 The matching layer caps without duplicates.
295 Raises:
296 ``server.error.LayerNotDefined``: If no layer is found.
297 """
298 lcs = []
300 for name in sr.list_param('LAYER'):
301 for lc in sr.layerCapsList:
302 if not server.layer_caps.layer_name_matches(lc, name):
303 continue
304 lcs.append(lc)
306 if not lcs:
307 raise server.error.LayerNotDefined()
309 return gws.u.uniq(lcs)
311 def bounds_for_tile(self, tms_uid, tm_uid, row, col):
312 """Compute the bounds of a tile.
314 Args:
315 tms_uid: Tile matrix set identifier.
316 tm_uid: Tile matrix identifier.
317 row: Tile row.
318 col: Tile column.
320 Returns:
321 The tile bounds, or ``None`` if the tile matrix set or the tile matrix is unknown.
322 """
323 tms = self.get_matrix_set(tms_uid)
324 if not tms:
325 return
326 tm = self.get_matrix(tms, tm_uid)
327 if not tm:
328 return
330 mg = self.grids[tms_uid]
331 z = int(tm.identifier)
332 return gws.Bounds(crs=tms.crs, extent=gws.lib.grid.extent_for_tile(mg, (col, row, z)))
334 def get_matrix_set(self, tms_uid):
335 """Find a tile matrix set by its identifier.
337 Args:
338 tms_uid: Tile matrix set identifier.
340 Returns:
341 The tile matrix set, or ``None`` if not found.
342 """
343 for tms in self.tileMatrixSets:
344 if tms.identifier == tms_uid:
345 return tms
347 def get_matrix(self, tms: gws.TileMatrixSet, tm_uid):
348 """Find a tile matrix in a tile matrix set by its identifier.
350 Args:
351 tms: Tile matrix set.
352 tm_uid: Tile matrix identifier.
354 Returns:
355 The tile matrix, or ``None`` if not found.
356 """
357 for tm in tms.matrices:
358 if tm.identifier == tm_uid:
359 return tm