예제 #1
0
    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.")
예제 #2
0
    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.",
        )
예제 #3
0
    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