Coverage for gws-app/gws/plugin/ows_server/wmts/__init__.py: 53%
115 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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 configured = {}
100 for p in self.cfg('grids', default=[]):
101 if not p.crs:
102 raise gws.ConfigurationError('wmts grid: crs is required')
103 crs = gws.lib.crs.require(p.crs)
104 if crs.srid in configured:
105 raise gws.ConfigurationError(f'wmts grid: duplicate crs {crs.srid}')
106 configured[crs.srid] = gws.lib.grid.new(
107 gws.lib.grid.Options(
108 crs=crs,
109 extent=p.extent,
110 baseResolution=p.baseResolution,
111 tileSize=p.tileSize,
112 withSnap=p.withSnap,
113 )
114 )
116 supported = {b.crs.srid for b in self.supportedBounds}
117 for srid in configured:
118 if srid not in supported:
119 raise gws.ConfigurationError(f'wmts grid: crs {srid} is not supported by the service')
121 # @TODO different matrix sets per layer
122 self.tileMatrixSets = []
123 self.grids = {}
124 for b in self.supportedBounds:
125 # see https://docs.opengeospatial.org/is/13-082r2/13-082r2.html#29
126 mg = configured.get(b.crs.srid) or gws.lib.grid.for_crs(b.crs)
127 ident = f'TMS_{b.crs.srid}'
128 self.grids[ident] = mg
129 self.tileMatrixSets.append(
130 gws.TileMatrixSet(
131 identifier=ident,
132 crs=b.crs,
133 matrices=self.make_tile_matrices(mg, 0, MAX_LEVEL),
134 )
135 )
137 def configure_templates(self):
138 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
140 def configure_operations(self):
141 self.supportedOperations = [
142 gws.OwsOperation(
143 verb=gws.OwsVerb.GetCapabilities,
144 mimeTypes=self.available_mime_types(gws.OwsVerb.GetCapabilities),
145 handlerName='handle_get_capabilities',
146 ),
147 gws.OwsOperation(
148 verb=gws.OwsVerb.GetLegendGraphic,
149 mimeTypes=self.available_mime_types(gws.OwsVerb.GetLegendGraphic),
150 handlerName='handle_get_legend_graphic',
151 ),
152 gws.OwsOperation(
153 verb=gws.OwsVerb.GetTile,
154 mimeTypes=self.available_mime_types(gws.OwsVerb.GetTile),
155 handlerName='handle_get_tile',
156 ),
157 ]
159 def make_tile_matrices(self, mg: gws.MapGrid, min_zoom, max_zoom):
160 """Create the tile matrices for a range of grid levels.
162 Args:
163 mg: Map grid.
164 min_zoom: First level.
165 max_zoom: Last level, inclusive.
167 Returns:
168 A list of tile matrices, one for each level.
169 """
170 ms = []
172 for z in range(min_zoom, max_zoom + 1):
173 nx, ny = gws.lib.grid.tile_count_for_level(mg, z)
174 res = gws.lib.grid.resolution_for_level(mg, z)
175 ms.append(
176 gws.TileMatrix(
177 identifier=f'{z:02d}',
178 scale=gws.gis.zoom.res_to_scale(res, mg.crs),
179 resolution=res,
180 x=mg.extent[0],
181 y=mg.extent[3],
182 tileWidth=mg.tileSize,
183 tileHeight=mg.tileSize,
184 width=nx,
185 height=ny,
186 extent=mg.extent,
187 )
188 )
190 return ms
192 ##
194 def init_request(self, req):
195 sr = super().init_request(req)
196 sr.require_project()
197 return sr
199 def layer_is_compatible(self, layer: gws.Layer):
200 return not layer.isGroup and layer.canRenderBox
202 ##
204 def handle_get_capabilities(self, sr: server.request.Object):
205 """Handle the GetCapabilities operation.
207 Args:
208 sr: Service request.
210 Returns:
211 The capabilities document.
212 """
213 return self.template_response(
214 sr,
215 sr.requested_format('FORMAT'),
216 layerCapsList=sr.layerCapsList,
217 tileMatrixSets=self.tileMatrixSets,
218 )
220 def handle_get_tile(self, sr: server.request.Object):
221 """Handle the GetTile operation.
223 Args:
224 sr: Service request.
226 Returns:
227 The tile image.
229 Raises:
230 ``server.error.InvalidParameterValue``: If not exactly one layer is requested.
231 ``server.error.TileOutOfRange``: If the tile matrix set or the tile matrix is unknown.
232 """
233 lcs = self.requested_layer_caps(sr)
234 if len(lcs) != 1:
235 raise server.error.InvalidParameterValue('LAYER')
237 tms_uid = sr.string_param('TILEMATRIXSET')
238 tm_uid = sr.string_param('TILEMATRIX')
239 row = sr.int_param('TILEROW')
240 col = sr.int_param('TILECOL')
242 bounds = self.bounds_for_tile(tms_uid, tm_uid, row, col)
243 if not bounds:
244 raise server.error.TileOutOfRange()
245 gws.log.debug(f'WMTS: bounds for tile {tms_uid=} {tm_uid=} {row=} {col=}: {bounds}')
247 mime = sr.requested_format('FORMAT')
248 ts = self.grids[tms_uid].tileSize
250 mri = gws.MapRenderInput(
251 backgroundColor=None,
252 bbox=bounds.extent,
253 targetCrs=bounds.crs,
254 mapSize=(ts, ts, gws.Uom.px),
255 planes=[
256 gws.MapRenderInputPlane(
257 type=gws.MapRenderInputPlaneType.imageLayer,
258 layer=lc.layer,
259 )
260 for lc in lcs
261 ],
262 )
264 mro = gws.gis.render.render_map(mri)
266 if self.root.app.developer_option('ows.annotate_wmts'):
267 e = bounds.extent
268 text = f'{tm_uid} {row} {col}\n{e[0]}\n{e[1]}\n{e[2]}\n{e[3]}'
269 mro.planes[0].image = mro.planes[0].image.add_text(text, x=10, y=10).add_box()
271 return self.image_response(sr, mro.planes[0].image, mime)
273 def handle_get_legend_graphic(self, sr: server.request.Object):
274 """Handle the GetLegendGraphic operation.
276 Args:
277 sr: Service request.
279 Returns:
280 The legend image of the requested layers.
281 """
282 lcs = self.requested_layer_caps(sr)
283 return self.render_legend(sr, lcs, sr.requested_format('FORMAT'))
285 ##
287 def requested_layer_caps(self, sr: server.request.Object):
288 """Find the layer caps for the layer names in the ``LAYER`` parameter.
290 Args:
291 sr: Service request.
293 Returns:
294 The matching layer caps without duplicates.
296 Raises:
297 ``server.error.LayerNotDefined``: If no layer is found.
298 """
299 lcs = []
301 for name in sr.list_param('LAYER'):
302 for lc in sr.layerCapsList:
303 if not server.layer_caps.layer_name_matches(lc, name):
304 continue
305 lcs.append(lc)
307 if not lcs:
308 raise server.error.LayerNotDefined()
310 return gws.u.uniq(lcs)
312 def bounds_for_tile(self, tms_uid, tm_uid, row, col):
313 """Compute the bounds of a tile.
315 Args:
316 tms_uid: Tile matrix set identifier.
317 tm_uid: Tile matrix identifier.
318 row: Tile row.
319 col: Tile column.
321 Returns:
322 The tile bounds, or ``None`` if the tile matrix set or the tile matrix is unknown.
323 """
324 tms = self.get_matrix_set(tms_uid)
325 if not tms:
326 return
327 tm = self.get_matrix(tms, tm_uid)
328 if not tm:
329 return
331 mg = self.grids[tms_uid]
332 z = int(tm.identifier)
333 return gws.Bounds(crs=tms.crs, extent=gws.lib.grid.extent_for_tile(mg, (col, row, z)))
335 def get_matrix_set(self, tms_uid):
336 """Find a tile matrix set by its identifier.
338 Args:
339 tms_uid: Tile matrix set identifier.
341 Returns:
342 The tile matrix set, or ``None`` if not found.
343 """
344 for tms in self.tileMatrixSets:
345 if tms.identifier == tms_uid:
346 return tms
348 def get_matrix(self, tms: gws.TileMatrixSet, tm_uid):
349 """Find a tile matrix in a tile matrix set by its identifier.
351 Args:
352 tms: Tile matrix set.
353 tm_uid: Tile matrix identifier.
355 Returns:
356 The tile matrix, or ``None`` if not found.
357 """
358 for tm in tms.matrices:
359 if tm.identifier == tm_uid:
360 return tm