Coverage for gws-app/gws/base/grabber/__init__.py: 100%

2 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Raster grabber. 

2 

3A grabber provides raster images for a layer: it fetches them from a source, 

4aligns them to a tile grid, reprojects them into the target CRS, stores tiles 

5and reads them back. It runs in-process; there is no separate tile server, 

6no generated configuration and no HTTP between GWS and the tile machinery. 

7 

8Submodules 

9---------- 

10 

11- ``core`` - the base grabber (`core.Object`) and its construction options 

12 (`core.Options`). It implements the public ``get_*`` API: tile and box 

13 lookup in the stores, block locking, mosaicking from stored tiles, and the 

14 shared helpers for encoding, decoding, warping and empty images. 

15- ``box`` - the base grabber for sources that render arbitrary boxes. It 

16 meta-tiles blocks, splits large requests into chunks and requests boxes in 

17 the source CRS. 

18- ``tile`` - the base grabber for sources addressed as tile pyramids. It 

19 mosaics source tiles of the best matching matrix and warps them onto the 

20 requested box. 

21 

22The interface is `gws.Grabber`; the tile stores are in ``gws.gis.cache.store`` 

23and the grid functions in ``gws.lib.grid``. 

24 

25Grids 

26----- 

27 

28There is one fixed tile grid per CRS, shared by all grabbers in that CRS 

29(``gws.lib.grid``). Projected CRS use the web mercator square as frame, one 

30256px tile at level 0 and the resolution ladder ``156543.03 / 2^z``; 

31geographic CRS use ``(-180, -90, 180, 90)``, two tiles at level 0 and 

32``0.703125 / 2^z`` degrees. The same level means the same ground resolution 

33in every projected CRS, and web mercator tile sources align 1:1 with the 

34EPSG:3857 grid. The ladder has no bottom: any resolution is served by 

35downsampling from the coarsest level that is not coarser than the target. 

36Levels beyond ``MAX_LEVEL`` (30) are not served. 

37 

38Each grabber restricts the grid to the layer's extent: a tile range per level 

39(``tile_range_for_level``), derived from the layer's WGS extent clipped to 

40the area of use of the CRS. Tiles outside the range are transparent. 

41 

42Grabbers and layers 

43------------------- 

44 

45An image layer holds one grabber per CRS the application supports 

46(``layer.grabbers``, keyed by SRID), created in 

47``base.layer.image.Object.post_configure_grabbers`` via the layer type's 

48``create_grabber``. A layer whose extent does not intersect a CRS gets no 

49grabber for it. Grabbers are plain objects, not nodes. 

50 

51Concrete grabbers live next to their provider (``plugin/.../grabber.py``) and 

52extend one of two base classes: 

53 

54- `box.Object` for sources that render arbitrary boxes (WMS, QGIS server, 

55 in-process MapServer for raster and MBTiles layers). Subclasses implement 

56 ``fetch_box_as_bytes`` and ``fetch_box_as_image``, one source request each. 

57- `tile.Object` for sources addressed as tile pyramids (tile services, WMTS). 

58 Subclasses provide ``sourceTms`` and ``fetch_tile_as_bytes``. 

59 

60Fetching and composing 

61---------------------- 

62 

63The public API (`gws.Grabber`) is ``get_tile_as_bytes/image``, 

64``get_tiles_as_bytes/image_dict`` and ``get_box_as_bytes/image``. ``get`` 

65may hit the store, ``compose`` assembles from parts and may issue several 

66source requests, ``fetch`` is exactly one source request. Both return forms 

67exist because encoding and decoding are not free; a caller asks for the 

68form it needs and never pays a round trip. 

69 

70Box sources are meta-tiled: a block of ``requestTiles`` x ``requestTiles`` 

71tiles is rendered as one request with a ``requestBuffer`` (pixels) around it, 

72then cut into tiles. This gives fewer source requests and consistent label 

73placement across tile seams. Requests larger than the provider's 

74``maxRequestPixels`` are split into chunks with the same buffer. 

75 

76Tile sources fetch the source tiles covering a target tile at the best 

77matching source matrix (never upscaling), mosaic and warp them. Fetched 

78source tiles are kept in the ephemeral store, so neighbouring target tiles 

79reuse them. There is no meta-tiling for tile sources. 

80 

81Reprojection is done by the base: source images are fetched in the source 

82CRS, clipped to the source CRS area of use, and warped into the target grid 

83with GDAL. A box outside the source area is transparent. For box sources, a 

84cross-CRS source request is capped at ``box.MAX_SOURCE_PIXEL_RATIO`` source 

85pixels per target pixel per side. 

86 

87Boxes at a stored level are mosaicked from stored tiles; otherwise (uncached 

88layers, levels beyond ``cache.maxLevel``, dynamic requests) they are composed 

89directly at the exact resolution, which keeps print output free of pyramid 

90resampling. 

91 

92Caching 

93------- 

94 

95Every grabber has two tile stores with the same layout 

96(``gws.gis.cache.store``): the persistent store under 

97``MAP_CACHE_DIR/<cache name>_<srid>`` holds levels up to ``cache.maxLevel`` 

98when the layer has ``withCache`` and a positive ``cache.maxAge``; everything 

99else goes to an ephemeral store under the ephemeral directory with a fixed 

100lifetime of ``EPHEMERAL_MAX_AGE`` (60 s), so that a block is composed once 

101per viewport even without a cache. 

102 

103The cache name is a content hash of the source binding (provider, source 

104layers, style, format, extent, request shaping), or ``cache.name`` when 

105configured; layers with identical bindings share stores. ``cache.crs`` 

106limits persistent caching to the listed CRS. 

107 

108Block composition is locked on block identity (``gws.u.server_lock``), so 

109concurrent requests for tiles of one block trigger one source fetch. A lock 

110busy for longer than ``BLOCK_LOCK_TIMEOUT`` (60 s) yields the transparent tile 

111and a warning. 

112 

113Dynamic requests 

114---------------- 

115 

116``params`` (e.g. the visible leaf set of a composite QGIS layer, passed by the 

117layer as ``lri.renderParams``) marks a request as dynamic. Dynamic requests 

118never touch the persistent store; they use an ephemeral store keyed by a 

119hash of the params and are meta-tiled and locked under the same key. Layers 

120must pass distilled params only, or every request goes uncached. 

121 

122Configuration 

123------------- 

124 

125On the layer: 

126 

127- ``withCache`` (default true) and ``cache``: ``maxAge`` (7d), ``maxLevel`` 

128 (18, a level of the global grid), ``requestTiles`` (4), ``requestBuffer`` 

129 (64), ``crs``, ``name``. 

130- ``imageFormat``: the stored and returned format (default PNG8); JPEG and 

131 other opaque formats render transparency as a white background. 

132- ``display``: ``tile`` (grabber tiles), ``box`` (grabber boxes) or, for 

133 ``tile`` and ``wmts`` layers, ``client`` (the browser fetches source tiles 

134 directly; requires a source grid in the map CRS). 

135 

136Per provider: ``maxRequestPixels`` for WMS (4096); MapServer-backed sources 

137use 9000. 

138 

139Cache management is ``gws cache status | seed | drop`` (``gws.gis.cache``), 

140which works on the layers' grabbers and stores. 

141 

142Example 

143------- 

144 

145A box grabber for a source that renders images on request:: 

146 

147 class Object(gws.base.grabber.box.Object): 

148 def __init__(self, opts: gws.base.grabber.Options, provider, sourceCrs: gws.Crs): 

149 super().__init__(opts) 

150 self.provider = provider 

151 self.sourceCrs = sourceCrs 

152 self.maxRequestPixels = provider.maxRequestPixels 

153 

154 def fetch_box_as_bytes(self, bounds, w, h, params=None): 

155 return self.provider.get_map(bounds, w, h, self.mimeType) 

156 

157 def fetch_box_as_image(self, bounds, w, h, params=None): 

158 return self.to_image(self.fetch_box_as_bytes(bounds, w, h, params)) 

159 

160The layer creates it in ``create_grabber`` and reads images from it:: 

161 

162 def create_grabber(self, opts): 

163 return my_grabber.Object(opts, provider=self.provider, sourceCrs=self.sourceCrs) 

164 

165 grabber = layer.grabbers[crs.srid] 

166 tile = grabber.get_tile_as_bytes((x, y, z)) 

167 img = grabber.get_box_as_image(extent, 800, 600) 

168 

169Notes and open issues 

170--------------------- 

171 

172- Source requests for the tiles covering one target tile are sequential; 

173 cross-CRS tile sources pay several upstream round trips per cold tile. 

174- Fetched source tiles stay in the ephemeral store for up to two hours, 

175 independent of the layer's cache settings. 

176- Meta-tiled composite QGIS layers need label placement that does not depend 

177 on the request extent (polygon labels: centroid of the whole polygon) and a 

178 ``requestBuffer`` at least as wide as the widest label, or labels differ 

179 across block seams. 

180- A source answering with an image of unexpected size raises 

181 ``gws.ExternalServiceError`` (empty tile, log entry) rather than being 

182 padded. 

183- The background color for opaque formats is not configurable. 

184- The own WMTS service composes a box per tile instead of reading stored 

185 tiles directly. 

186""" 

187 

188from .core import Options, Object 

189from . import box, tile