def test_make_bbox_to_pixels_transf_same(self): src_bbox = [0, 0, 1, 1] dest_bbox = [0, 0, 1, 1] transf = utils.make_bbox_to_pixels_transf(src_bbox, dest_bbox) point = [0.5, 0.5] transformed = transf(*point) self.assertEqual(tuple(point), transformed, "Expected linear transformation to return the same coords.")
def test_make_bbox_to_pixels_transf_diff(self): multiplication_factor = 10 src_bbox = [0, 0, 1, 1] dest_bbox = [0, 0, multiplication_factor * src_bbox[2], multiplication_factor * src_bbox[3]] transf = utils.make_bbox_to_pixels_transf(src_bbox, dest_bbox) point = [0.5, 0.5] transformed = transf(*point) self.assertEqual( tuple(multiplication_factor * coord for coord in point), transformed, "Expected linear transformation to return multiplied coords.", )
def fetch(self, bbox: typing.List, zoom: int = None, *args, **kwargs): """ The function fetching tiles from a Slippy Map provider, composing them into a single image, and cropping it to match the given BBOX. Retrieval of each tile is repeated self.max_retries times, waiting self.retry_delay seconds between consecutive requests. :param bbox: bounding box of the background image, dataset compliant format: [west, east, south, north, CRS] :param zoom: zoom with which to retrieve Slippy Map's tiles (by default, it's calculated based on width, height) :return: None if the CRS is different from self.tiles_crs, or background Image """ if not self.url: logger.error("Thumbnail background requires url to be configured.") raise ThumbnailError("Tiled background improperly configured.") if bbox[-1].lower() != self.crs.lower(): # background service is not available the requested CRS CRS logger.debug( f"Thumbnail background generation skipped. " f"Clashing CRSs: requested {bbox[-1]}, supported {self.crs}") return bbox = [float(coord) for coord in bbox[0:4]] # check if BBOX fits within the EPSG:3857 map, if not - return an empty background if bbox[2] > self._epsg3857_max_y or bbox[3] < -self._epsg3857_max_y: return Image.new("RGB", (self.thumbnail_width, self.thumbnail_height), (250, 250, 250)) bbox4326 = self.bbox3857to4326(*bbox) # change bbox from dataset (left, right, bottom, top) to mercantile (left, bottom, right, top) self._mercantile_bbox = [ bbox4326[0], bbox4326[2], bbox4326[1], bbox4326[3] ] # calculate zoom level if zoom is None: zoom = self.calculate_zoom() else: zoom = int(zoom) top_left_tile = mercantile.tile(bbox4326[0], bbox4326[3], zoom) bottom_right_tile = mercantile.tile(bbox4326[1], bbox4326[2], zoom) # rescaling factors - indicators of how west and east BBOX boundaries are offset in respect to the world's map; # east and west boundaries may exceed the maximum coordinate of the world in EPSG:3857. In such case additinal # number of tiles need to be fetched to compose the image and the boundary tiles' coordinates need to be # rescaled to ensure the proper image cropping. epsg3857_world_width = 2 * self._epsg3857_max_x west_rescaling_factor = 0 if abs(bbox[0]) > self._epsg3857_max_x: west_rescaling_factor = ceil( (abs(bbox[0]) - self._epsg3857_max_x) / epsg3857_world_width) * copysign(1, bbox[0]) east_rescaling_factor = 0 if abs(bbox[1]) > self._epsg3857_max_x: east_rescaling_factor = ceil( (abs(bbox[1]) - self._epsg3857_max_x) / epsg3857_world_width) * copysign(1, bbox[1]) map_row_tiles = 2**zoom - 1 # number of tiles in the Map's row for a certain zoom level map_worlds = int(east_rescaling_factor - west_rescaling_factor) # number maps in an image worlds_between = map_worlds - 1 # number of full maps in an image if top_left_tile.x > bottom_right_tile.x or bbox[1] - bbox[ 0] > epsg3857_world_width or map_worlds > 0: # BBOX crosses Slippy Map's border if worlds_between > 0: tiles_rows = (list(range(top_left_tile.x, map_row_tiles + 1)) + worlds_between * list(range(map_row_tiles + 1)) + list(range(bottom_right_tile.x + 1))) else: tiles_rows = list(range(top_left_tile.x, map_row_tiles + 1)) + list( range(bottom_right_tile.x + 1)) else: # BBOx is contained by the Slippy Map if worlds_between > 0: tiles_rows = list( range(top_left_tile.x, bottom_right_tile.x + 1)) + worlds_between * list(range(map_row_tiles + 1)) else: tiles_rows = list( range(top_left_tile.x, bottom_right_tile.x + 1)) tiles_cols = list(range(top_left_tile.y, bottom_right_tile.y + 1)) # if latitude boundaries extend world's height - add background's height, and set constant Y offset for tiles additional_height = 0 fixed_top_offset = 0 fixed_bottom_offset = 0 north_extension3857 = max(0, bbox[3] - self._epsg3857_max_y) south_extension3857 = abs(min(0, bbox[2] + self._epsg3857_max_y)) extension3857 = north_extension3857 + south_extension3857 if extension3857: # get single tile's height in ESPG:3857 tile_bounds = mercantile.bounds(tiles_rows[0], tiles_cols[0], zoom) _, south = self.point4326to3857(getattr(tile_bounds, "west"), getattr(tile_bounds, "south")) _, north = self.point4326to3857(getattr(tile_bounds, "west"), getattr(tile_bounds, "north")) tile_hight3857 = north - south additional_height = round( self.tile_size * extension3857 / tile_hight3857) # based on linear proportion if north_extension3857: fixed_top_offset = round(self.tile_size * north_extension3857 / tile_hight3857) if south_extension3857: fixed_bottom_offset = round( self.tile_size * south_extension3857 / tile_hight3857) background = Image.new( "RGB", (len(tiles_rows) * self.tile_size, len(tiles_cols) * self.tile_size + additional_height), (250, 250, 250), ) for offset_x, x in enumerate(tiles_rows): for offset_y, y in enumerate(tiles_cols): if self.tms: y = (2**zoom) - y - 1 imgurl = self.url.format(x=x, y=y, z=zoom) im = None for retries in range(self.max_retries): try: resp, content = http_client.request(imgurl) if resp.status_code > 400: retries = self.max_retries - 1 raise Exception(f"{strip_tags(content)}") im = BytesIO(content) Image.open( im).verify() # verify that it is, in fact an image break except Exception as e: logger.error( f"Thumbnail background fetching from {imgurl} failed {retries} time(s) with: {e}" ) if retries + 1 == self.max_retries: raise e time.sleep(self.retry_delay) continue if im: image = Image.open( im ) # "re-open" the file (required after running verify method) # add the fetched tile to the background image, placing it under proper coordinates background.paste( image, (offset_x * self.tile_size, offset_y * self.tile_size + fixed_top_offset)) # get BBOX of the tiles top_left_bounds = mercantile.bounds(top_left_tile) bottom_right_bounds = mercantile.bounds(bottom_right_tile) tiles_bbox3857 = self.bbox4326to3857( getattr(top_left_bounds, "west"), getattr(bottom_right_bounds, "east"), getattr(bottom_right_bounds, "south"), getattr(top_left_bounds, "north"), ) # rescale tiles' boundaries - if space covered by the input BBOX extends the width of the world, # (e.g. two "worlds" are present on the map), translation between tiles' BBOX and image's pixel requires # additional rescaling, for tiles' BBOX coordinates to match input BBOX coordinates west_coord = tiles_bbox3857[ 0] + west_rescaling_factor * epsg3857_world_width east_coord = tiles_bbox3857[ 1] + east_rescaling_factor * epsg3857_world_width # prepare translating function from received BBOX to pixel values of the background image src_quad = (0, fixed_top_offset, background.size[0], background.size[1] - fixed_bottom_offset) to_src_px = utils.make_bbox_to_pixels_transf( [west_coord, tiles_bbox3857[2], east_coord, tiles_bbox3857[3]], src_quad) # translate received BBOX to pixel values minx, miny = to_src_px(bbox[0], bbox[2]) maxx, maxy = to_src_px(bbox[1], bbox[3]) # max and min function for Y axis were introduced to mitigate rounding errors crop_box = ( ceil(minx), max(ceil(maxy) + fixed_top_offset, 0), floor(maxx), min(floor(miny) + fixed_top_offset, background.size[1]), ) if not all([ 0 <= crop_x <= background.size[0] for crop_x in [crop_box[0], crop_box[2]] ]): raise ThumbnailError( f"Tiled background cropping error. Boundaries outside of the image: {crop_box}" ) # crop background image to the desired bbox and resize it background = background.crop(box=crop_box) background = background.resize( (self.thumbnail_width, self.thumbnail_height)) if sum(background.convert("L").getextrema()) in (0, 2): # either all black or all white logger.error("Thumbnail background outside the allowed area.") raise ThumbnailError( "Thumbnail background outside the allowed area.") return background