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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Base grabber for boxed sources."""
3import math
5import gws
6import gws.lib.extent
7import gws.lib.grid
8import gws.lib.image
10from . import core
12MAX_SOURCE_PIXEL_RATIO = 4
13"""Cap on source pixels per target pixel per side, for cross-CRS requests."""
16class Object(core.Object):
17 """Base grabber for sources that render arbitrary boxes.
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.
23 Subclasses implement ``fetch_box_as_bytes`` and ``fetch_box_as_image``.
24 """
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."""
31 def __init__(self, opts: core.Options):
32 """Create a box grabber.
34 ``requestTiles`` and ``requestBuffer`` are taken from the cache settings,
35 ``maxRequestPixels`` defaults to 4096.
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
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)
50 level_mtr = self.tile_range_for_level(z)
51 lx0, ly0, lx1, ly1, _ = level_mtr
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
62 tile_size = self.grid.tileSize
63 buf_size = self.requestBuffer
64 res = gws.lib.grid.resolution_for_level(self.grid, z)
66 extent = gws.lib.grid.extent_for_range(self.grid, block_mtr)
67 extent = gws.lib.extent.buffer(extent, buf_size * res)
69 w = (bx1 - bx0 + 1) * tile_size + buf_size * 2
70 h = (by1 - by0 + 1) * tile_size + buf_size * 2
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()
76 block_images = {}
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())
84 return block_images
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)
90 if self.sourceCrs == self.targetCrs:
91 return self._fetch_and_compose_box(extent, w, h, params)
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)
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)
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)
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)
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 )
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.
131 Subclasses must implement this.
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.
139 Returns:
140 The encoded image.
142 Raises:
143 ``NotImplementedError``: In the base class.
144 """
146 raise NotImplementedError(f'fetch_box_as_bytes not implemented in {self!r}')
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.
151 Subclasses must implement this.
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.
159 Returns:
160 The image.
162 Raises:
163 ``NotImplementedError``: In the base class.
164 """
166 raise NotImplementedError(f'fetch_box_as_image not implemented in {self!r}')
168 ##
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``."""
173 max_pix = self.maxRequestPixels
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)
180 buf_size = self.requestBuffer
181 chunk_size = max_pix - buf_size * 2
183 x_res = gws.lib.extent.w(extent) / w
184 y_res = gws.lib.extent.h(extent) / h
186 canvas = gws.lib.image.from_size((w, h))
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))
217 return canvas