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

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

115 

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

120 

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 ) 

136 

137 def configure_templates(self): 

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

139 

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 ] 

158 

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

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

161 

162 Args: 

163 mg: Map grid. 

164 min_zoom: First level. 

165 max_zoom: Last level, inclusive. 

166 

167 Returns: 

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

169 """ 

170 ms = [] 

171 

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 ) 

189 

190 return ms 

191 

192 ## 

193 

194 def init_request(self, req): 

195 sr = super().init_request(req) 

196 sr.require_project() 

197 return sr 

198 

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

200 return not layer.isGroup and layer.canRenderBox 

201 

202 ## 

203 

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

205 """Handle the GetCapabilities operation. 

206 

207 Args: 

208 sr: Service request. 

209 

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 ) 

219 

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

221 """Handle the GetTile operation. 

222 

223 Args: 

224 sr: Service request. 

225 

226 Returns: 

227 The tile image. 

228 

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

236 

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

241 

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

246 

247 mime = sr.requested_format('FORMAT') 

248 ts = self.grids[tms_uid].tileSize 

249 

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 ) 

263 

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

265 

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

270 

271 return self.image_response(sr, mro.planes[0].image, mime) 

272 

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

274 """Handle the GetLegendGraphic operation. 

275 

276 Args: 

277 sr: Service request. 

278 

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

284 

285 ## 

286 

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

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

289 

290 Args: 

291 sr: Service request. 

292 

293 Returns: 

294 The matching layer caps without duplicates. 

295 

296 Raises: 

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

298 """ 

299 lcs = [] 

300 

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) 

306 

307 if not lcs: 

308 raise server.error.LayerNotDefined() 

309 

310 return gws.u.uniq(lcs) 

311 

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

313 """Compute the bounds of a tile. 

314 

315 Args: 

316 tms_uid: Tile matrix set identifier. 

317 tm_uid: Tile matrix identifier. 

318 row: Tile row. 

319 col: Tile column. 

320 

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 

330 

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

334 

335 def get_matrix_set(self, tms_uid): 

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

337 

338 Args: 

339 tms_uid: Tile matrix set identifier. 

340 

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 

347 

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

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

350 

351 Args: 

352 tms: Tile matrix set. 

353 tm_uid: Tile matrix identifier. 

354 

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