Coverage for gws-app/gws/base/grabber/box.py: 96%

91 statements  

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

1"""Base grabber for boxed sources.""" 

2 

3import math 

4 

5import gws 

6import gws.lib.extent 

7import gws.lib.grid 

8import gws.lib.image 

9 

10from . import core 

11 

12MAX_SOURCE_PIXEL_RATIO = 4 

13"""Cap on source pixels per target pixel per side, for cross-CRS requests.""" 

14 

15 

16class Object(core.Object): 

17 """Base grabber for sources that render arbitrary boxes. 

18 

19 Composes tiles and boxes from source requests in the source CRS. Provides 

20 subclasses with meta-tiling of tile blocks, splitting of large requests 

21 into chunks and reprojection into the target CRS. 

22 

23 Subclasses implement ``fetch_box_as_bytes`` and ``fetch_box_as_image``. 

24 """ 

25 

26 maxRequestPixels: int 

27 """Cap on the pixel size of one source request; larger boxes are fetched in chunks.""" 

28 requestBuffer: int 

29 """Pixels rendered around a block or chunk and cropped, for consistent labels across seams.""" 

30 

31 def __init__(self, opts: core.Options): 

32 """Create a box grabber. 

33 

34 ``requestTiles`` and ``requestBuffer`` are taken from the cache settings, 

35 ``maxRequestPixels`` defaults to 4096. 

36 

37 Args: 

38 opts: Grabber options. 

39 """ 

40 super().__init__(opts) 

41 self.requestTiles = self.cache.requestTiles 

42 self.requestBuffer = self.cache.requestBuffer 

43 self.maxRequestPixels = 4096 

44 

45 def compose_tile_block_as_image_dict(self, mt, params=None): 

46 z = mt[-1] 

47 n = self.requestTiles 

48 bx, by, _ = self.block_start_tile(mt) 

49 

50 level_mtr = self.tile_range_for_level(z) 

51 lx0, ly0, lx1, ly1, _ = level_mtr 

52 

53 block_mtr = ( 

54 max(bx, lx0), 

55 max(by, ly0), 

56 min(bx + n - 1, lx1), 

57 min(by + n - 1, ly1), 

58 z, 

59 ) 

60 bx0, by0, bx1, by1, _ = block_mtr 

61 

62 tile_size = self.grid.tileSize 

63 buf_size = self.requestBuffer 

64 res = gws.lib.grid.resolution_for_level(self.grid, z) 

65 

66 extent = gws.lib.grid.extent_for_range(self.grid, block_mtr) 

67 extent = gws.lib.extent.buffer(extent, buf_size * res) 

68 

69 w = (bx1 - bx0 + 1) * tile_size + buf_size * 2 

70 h = (by1 - by0 + 1) * tile_size + buf_size * 2 

71 

72 img = self.compose_box_as_image(extent, w, h, params) 

73 img.crop((buf_size, buf_size, w - buf_size, h - buf_size)) 

74 pixels = img.to_array() 

75 

76 block_images = {} 

77 

78 for tx, ty, _ in gws.lib.grid.enum_tiles(block_mtr): 

79 px = (tx - bx0) * tile_size 

80 py = (ty - by0) * tile_size 

81 slice = pixels[py : py + tile_size, px : px + tile_size] 

82 block_images[(tx, ty, z)] = gws.lib.image.from_array(slice.copy()) 

83 

84 return block_images 

85 

86 def compose_box_as_image(self, extent, w, h, params=None): 

87 w = gws.u.to_rounded_int(w) 

88 h = gws.u.to_rounded_int(h) 

89 

90 if self.sourceCrs == self.targetCrs: 

91 return self._fetch_and_compose_box(extent, w, h, params) 

92 

93 src_extent = self.extent_to_source_crs(extent) 

94 if not src_extent: 

95 gws.log.debug(f'grabber {self.cache.name!r}: empty image: box {extent!r} outside the source CRS area') 

96 return self.empty_image(w, h) 

97 

98 target_res = gws.lib.extent.w(extent) / w 

99 src_res = self.targetCrs.transform_resolution(extent, target_res, self.sourceCrs) 

100 if not src_res: 

101 gws.log.debug(f'grabber {self.cache.name!r}: empty image: no source resolution for box {extent!r}') 

102 return self.empty_image(w, h) 

103 

104 src_extent = gws.lib.extent.buffer(src_extent, src_res * 2) 

105 sw = math.ceil(gws.lib.extent.w(src_extent) / src_res) 

106 sh = math.ceil(gws.lib.extent.h(src_extent) / src_res) 

107 

108 # src_res is the finest resolution anywhere in the box, so where the projection is strongly 

109 # distorted the source request can grow far beyond the output. Cap it at N times the output 

110 # per side, shrinking both sides by the same factor to keep the source pixels square. 

111 factor = max( 

112 sw / (w * MAX_SOURCE_PIXEL_RATIO), 

113 sh / (h * MAX_SOURCE_PIXEL_RATIO), 

114 ) 

115 if factor > 1: 

116 sw = math.ceil(sw / factor) 

117 sh = math.ceil(sh / factor) 

118 

119 img = self._fetch_and_compose_box(src_extent, sw, sh, params) 

120 return self.warp_image( 

121 img, 

122 gws.Bounds(crs=self.sourceCrs, extent=src_extent), 

123 gws.Bounds(crs=self.targetCrs, extent=extent), 

124 w, 

125 h, 

126 ) 

127 

128 def fetch_box_as_bytes(self, bounds: gws.Bounds, w: int, h: int, params: dict | None = None) -> bytes: 

129 """Fetch a box from the source with exactly one request, as encoded bytes. 

130 

131 Subclasses must implement this. 

132 

133 Args: 

134 bounds: Box bounds in the source CRS. 

135 w: Width in pixels. 

136 h: Height in pixels. 

137 params: Dynamic request parameters. 

138 

139 Returns: 

140 The encoded image. 

141 

142 Raises: 

143 ``NotImplementedError``: In the base class. 

144 """ 

145 

146 raise NotImplementedError(f'fetch_box_as_bytes not implemented in {self!r}') 

147 

148 def fetch_box_as_image(self, bounds: gws.Bounds, w: int, h: int, params: dict | None = None) -> gws.Image: 

149 """Fetch a box from the source with exactly one request, as an image. 

150 

151 Subclasses must implement this. 

152 

153 Args: 

154 bounds: Box bounds in the source CRS. 

155 w: Width in pixels. 

156 h: Height in pixels. 

157 params: Dynamic request parameters. 

158 

159 Returns: 

160 The image. 

161 

162 Raises: 

163 ``NotImplementedError``: In the base class. 

164 """ 

165 

166 raise NotImplementedError(f'fetch_box_as_image not implemented in {self!r}') 

167 

168 ## 

169 

170 def _fetch_and_compose_box(self, extent: gws.Extent, w: int, h: int, params: dict | None) -> gws.Image: 

171 """Fetch a source-CRS box, split into chunks of at most ``maxRequestPixels``.""" 

172 

173 max_pix = self.maxRequestPixels 

174 

175 if w <= max_pix and h <= max_pix: 

176 b = gws.Bounds(crs=self.sourceCrs, extent=extent) 

177 img = self.fetch_box_as_image(b, w, h, params) 

178 return self.normalize_image(img, w, h) 

179 

180 buf_size = self.requestBuffer 

181 chunk_size = max_pix - buf_size * 2 

182 

183 x_res = gws.lib.extent.w(extent) / w 

184 y_res = gws.lib.extent.h(extent) / h 

185 

186 canvas = gws.lib.image.from_size((w, h)) 

187 

188 for sy in range(0, h, chunk_size): 

189 for sx in range(0, w, chunk_size): 

190 chunk_w = min(chunk_size, w - sx) 

191 chunk_h = min(chunk_size, h - sy) 

192 fetch_w = chunk_w + buf_size * 2 

193 fetch_h = chunk_h + buf_size * 2 

194 e = ( 

195 extent[0] + (sx - buf_size) * x_res, 

196 extent[3] - (sy + chunk_h + buf_size) * y_res, 

197 extent[0] + (sx + chunk_w + buf_size) * x_res, 

198 extent[3] - (sy - buf_size) * y_res, 

199 ) 

200 img = self.fetch_box_as_image( 

201 gws.Bounds(crs=self.sourceCrs, extent=e), 

202 fetch_w, 

203 fetch_h, 

204 params, 

205 ) 

206 img = self.normalize_image(img, fetch_w, fetch_h) 

207 img.crop( 

208 ( 

209 buf_size, 

210 buf_size, 

211 buf_size + chunk_w, 

212 buf_size + chunk_h, 

213 ) 

214 ) 

215 canvas.paste(img, (sx, sy)) 

216 

217 return canvas