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

1"""WMTS service. 

2 

3Implements WMTS 1.0.0 with the operations GetCapabilities, GetTile and 

4GetLegendGraphic. Only ``GET`` requests with ``KVP`` encoding are supported. 

5 

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. 

12 

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. 

16 

17Templates: 

18 

19- ``templates/getCapabilities.cx.py``: ``ows.GetCapabilities``. 

20 

21References: 

22 

23- OGC 07-057r7 (https://portal.ogc.org/files/?artifact_id=35326) 

24 

25Example:: 

26 

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""" 

39 

40from typing import Optional 

41 

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 

50 

51 

52MAX_LEVEL = 20 

53"""Finest advertised tile matrix level.""" 

54 

55 

56@gws.ext.config.owsService('wmts') 

57class Config(server.service.Config): 

58 """WMTS service that serves the project layers as tiles.""" 

59 

60 grids: Optional[list[gws.lib.grid.Config]] 

61 """Tile matrix grids, one per CRS. (added in 8.5)""" 

62 

63 

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] 

73 

74_DEFAULT_METADATA = gws.Metadata( 

75 inspireDegreeOfConformity='notEvaluated', 

76 inspireMandatoryKeyword='infoMapAccessService', 

77 inspireResourceType='service', 

78 inspireSpatialDataServiceType='view', 

79 isoScope='dataset', 

80 isoSpatialRepresentationType='vector', 

81) 

82 

83 

84@gws.ext.object.owsService('wmts') 

85class Object(server.service.Object): 

86 """WMTS service that renders the project layers as tiles.""" 

87 

88 protocol = gws.OwsProtocol.WMTS 

89 supportedVersions = ['1.0.0'] 

90 isRasterService = True 

91 isOwsCommon = True 

92 

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.""" 

97 

98 def configure(self): 

99 gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES) 

100 

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 ) 

117 

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') 

122 

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 ) 

138 

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 ] 

157 

158 def make_tile_matrices(self, mg: gws.MapGrid, min_zoom, max_zoom): 

159 """Create the tile matrices for a range of grid levels. 

160 

161 Args: 

162 mg: Map grid. 

163 min_zoom: First level. 

164 max_zoom: Last level, inclusive. 

165 

166 Returns: 

167 A list of tile matrices, one for each level. 

168 """ 

169 ms = [] 

170 

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 ) 

188 

189 return ms 

190 

191 ## 

192 

193 def init_request(self, req): 

194 sr = super().init_request(req) 

195 sr.require_project() 

196 return sr 

197 

198 def layer_is_compatible(self, layer: gws.Layer): 

199 return not layer.isGroup and layer.canRenderBox 

200 

201 ## 

202 

203 def handle_get_capabilities(self, sr: server.request.Object): 

204 """Handle the GetCapabilities operation. 

205 

206 Args: 

207 sr: Service request. 

208 

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 ) 

218 

219 def handle_get_tile(self, sr: server.request.Object): 

220 """Handle the GetTile operation. 

221 

222 Args: 

223 sr: Service request. 

224 

225 Returns: 

226 The tile image. 

227 

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') 

235 

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') 

240 

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}') 

245 

246 mime_type = sr.requested_format('FORMAT') 

247 ts = self.grids[tms_uid].tileSize 

248 

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 ) 

262 

263 mro = gws.gis.render.render_map(mri) 

264 

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() 

269 

270 return self.image_response(sr, mro.planes[0].image, mime_type) 

271 

272 def handle_get_legend_graphic(self, sr: server.request.Object): 

273 """Handle the GetLegendGraphic operation. 

274 

275 Args: 

276 sr: Service request. 

277 

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')) 

283 

284 ## 

285 

286 def requested_layer_caps(self, sr: server.request.Object): 

287 """Find the layer caps for the layer names in the ``LAYER`` parameter. 

288 

289 Args: 

290 sr: Service request. 

291 

292 Returns: 

293 The matching layer caps without duplicates. 

294 

295 Raises: 

296 ``server.error.LayerNotDefined``: If no layer is found. 

297 """ 

298 lcs = [] 

299 

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) 

305 

306 if not lcs: 

307 raise server.error.LayerNotDefined() 

308 

309 return gws.u.uniq(lcs) 

310 

311 def bounds_for_tile(self, tms_uid, tm_uid, row, col): 

312 """Compute the bounds of a tile. 

313 

314 Args: 

315 tms_uid: Tile matrix set identifier. 

316 tm_uid: Tile matrix identifier. 

317 row: Tile row. 

318 col: Tile column. 

319 

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 

329 

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))) 

333 

334 def get_matrix_set(self, tms_uid): 

335 """Find a tile matrix set by its identifier. 

336 

337 Args: 

338 tms_uid: Tile matrix set identifier. 

339 

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 

346 

347 def get_matrix(self, tms: gws.TileMatrixSet, tm_uid): 

348 """Find a tile matrix in a tile matrix set by its identifier. 

349 

350 Args: 

351 tms: Tile matrix set. 

352 tm_uid: Tile matrix identifier. 

353 

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