diff --git a/geonode/base/bbox_utils.py b/geonode/base/bbox_utils.py index dc7ca8c4baf..b589e1b9f16 100644 --- a/geonode/base/bbox_utils.py +++ b/geonode/base/bbox_utils.py @@ -20,15 +20,24 @@ import math import copy import json +import re +import logging from decimal import Decimal from typing import Union, List, Generator + +from pyproj import CRS from shapely import affinity from shapely.ops import split from shapely.geometry import mapping, Polygon, LineString, GeometryCollection from django.contrib.gis.geos import Polygon as DjangoPolygon +from geonode import GeoNodeException +from geonode.utils import bbox_to_projection + +logger = logging.getLogger(__name__) + class BBOXHelper: """ @@ -228,3 +237,92 @@ def split_polygon( return GeometryCollection(geo_polygons) else: return geo_polygons + + +def transform_bbox(bbox: List, target_crs: str = "EPSG:3857"): + """ + Function transforming BBOX in dataset compliant format (xmin, xmax, ymin, ymax, 'EPSG:xxxx') to another CRS, + preserving overflow values. + """ + match = re.match(r"^(EPSG:)?(?P\d{4,6})$", str(target_crs)) + target_srid = int(match.group("srid")) if match else 4326 + return list(bbox_to_projection(bbox, target_srid=target_srid))[:-1] + [target_crs] + + +def epsg_3857_area_of_use(target_crs="EPSG:4326"): + """ + Shortcut function, returning area of use of EPSG:3857 (in EPSG:4326) in a dataset compliant BBOX + """ + epsg3857 = CRS.from_user_input("EPSG:3857") + area_of_use = [ + getattr(epsg3857.area_of_use, "west"), + getattr(epsg3857.area_of_use, "east"), + getattr(epsg3857.area_of_use, "south"), + getattr(epsg3857.area_of_use, "north"), + "EPSG:4326", + ] + if target_crs != "EPSG:4326": + return transform_bbox(area_of_use, target_crs) + return area_of_use + + +def crop_to_3857_area_of_use(bbox: List) -> List: + # perform the comparison in EPSG:4326 (the pivot for EPSG:3857) + bbox4326 = transform_bbox(bbox, target_crs="EPSG:4326") + + # get area of use of EPSG:3857 in EPSG:4326 + epsg3857_bounds_bbox = epsg_3857_area_of_use() + + bbox = [] + for coord, bound_coord in zip(bbox4326[:-1], epsg3857_bounds_bbox[:-1]): + if abs(coord) > abs(bound_coord): + logger.debug("Thumbnail generation: cropping BBOX's coord to EPSG:3857 area of use.") + bbox.append(bound_coord) + else: + bbox.append(coord) + + bbox.append("EPSG:4326") + + return bbox + + +def exceeds_epsg3857_area_of_use(bbox: List) -> bool: + """ + Function checking if a provided BBOX extends the are of use of EPSG:3857. Comparison is performed after casting + the BBOX to EPSG:4326 (pivot for EPSG:3857). + + :param bbox: a dataset compliant BBOX in a certain CRS, in (xmin, xmax, ymin, ymax, 'EPSG:xxxx') order + :returns: List of indicators whether BBOX's coord exceeds the area of use of EPSG:3857 + """ + + # perform the comparison in EPSG:4326 (the pivot for EPSG:3857) + bbox4326 = transform_bbox(bbox, target_crs="EPSG:4326") + + # get area of use of EPSG:3857 in EPSG:4326 + epsg3857_bounds_bbox = epsg_3857_area_of_use() + + exceeds = False + for coord, bound_coord in zip(bbox4326[:-1], epsg3857_bounds_bbox[:-1]): + if abs(coord) > abs(bound_coord): + exceeds = True + + return exceeds + + +def clean_bbox(bbox, target_crs): + # make sure BBOX is provided with the CRS in a correct format + source_crs = bbox[-1] + + srid_regex = re.match(r"EPSG:\d+", source_crs) + if not srid_regex: + logger.error(f"Thumbnail bbox is in a wrong format: {bbox}") + raise GeoNodeException("Wrong BBOX format") + + # for the EPSG:3857 (default thumb's CRS) - make sure received BBOX can be transformed to the target CRS; + # if it can't be (original coords are outside of the area of use of EPSG:3857), thumbnail generation with + # the provided bbox is impossible. + if target_crs == "EPSG:3857" and bbox[-1].upper() != "EPSG:3857": + bbox = crop_to_3857_area_of_use(bbox) + + bbox = transform_bbox(bbox, target_crs=target_crs) + return bbox diff --git a/geonode/maps/models.py b/geonode/maps/models.py index 32f9e60dd8e..bbc546865a0 100644 --- a/geonode/maps/models.py +++ b/geonode/maps/models.py @@ -26,7 +26,7 @@ from django.template.defaultfilters import slugify from django.urls import reverse from django.utils.translation import gettext_lazy as _ - +from geonode.base import bbox_utils from geonode import geoserver # noqa from geonode.base.models import ResourceBase, LinkedResource from geonode.client.hooks import hookset @@ -129,23 +129,37 @@ def get_absolute_url(self): def embed_url(self): return reverse("map_embed", kwargs={"mapid": self.pk}) - def get_bbox_from_datasets(self, layers): + def compute_bbox(self, target_crs="EPSG:3857"): """ - Calculate the bbox from a given list of Dataset objects - - bbox format: [xmin, xmax, ymin, ymax] + Compute bbox for maps by looping on all maplayers and getting the max + bbox of all the datasets """ - bbox = None - for layer in layers: - dataset_bbox = layer.bbox - if bbox is None: - bbox = list(dataset_bbox[0:4]) - else: - bbox[0] = min(bbox[0], dataset_bbox[0]) - bbox[1] = max(bbox[1], dataset_bbox[1]) - bbox[2] = min(bbox[2], dataset_bbox[2]) - bbox[3] = max(bbox[3], dataset_bbox[3]) - + bbox = bbox_utils.epsg_3857_area_of_use(target_crs="EPSG:3857") + for layer in self.maplayers.filter(visibility=True).order_by("order").iterator(): + dataset = layer.dataset + if dataset is not None: + if dataset.ll_bbox_polygon: + dataset_bbox = bbox_utils.clean_bbox(dataset.ll_bbox, target_crs) + elif ( + dataset.bbox[-1].upper() != "EPSG:3857" + and target_crs.upper() == "EPSG:3857" + and bbox_utils.exceeds_epsg3857_area_of_use(dataset.bbox) + ): + # handle exceeding the area of use of the default thumb's CRS + dataset_bbox = bbox_utils.transform_bbox( + bbox_utils.crop_to_3857_area_of_use(dataset.bbox), target_crs + ) + else: + dataset_bbox = bbox_utils.transform_bbox(dataset.bbox, target_crs) + + bbox = [ + max(bbox[0], dataset_bbox[0]), + min(bbox[1], dataset_bbox[1]), + max(bbox[2], dataset_bbox[2]), + min(bbox[3], dataset_bbox[3]), + ] + + self.set_bbox_polygon([bbox[0], bbox[2], bbox[1], bbox[3]], target_crs) return bbox @property diff --git a/geonode/resource/utils.py b/geonode/resource/utils.py index 4edfd9ccd47..0fe66a01f82 100644 --- a/geonode/resource/utils.py +++ b/geonode/resource/utils.py @@ -45,7 +45,7 @@ HierarchicalKeyword, SpatialRepresentationType, ) - +from geonode.maps.models import Map from ..layers.models import Dataset from ..documents.models import Document from ..documents.enumerations import DOCUMENT_TYPE_MAP, DOCUMENT_MIMETYPE_MAP @@ -408,6 +408,12 @@ def metadata_post_save(instance, *args, **kwargs): instance.uuid = _uuid Dataset.objects.filter(id=instance.id).update(uuid=_uuid) + if isinstance(instance, Map): + """ + For maps, we can calculate the bbox based on the maplayers + """ + instance.compute_bbox() + # Set a default user for accountstream to work correctly. if instance.owner is None: instance.owner = get_valid_user() diff --git a/geonode/thumbs/tests/test_unit.py b/geonode/thumbs/tests/test_unit.py index e344c823092..5d37124fcd7 100644 --- a/geonode/thumbs/tests/test_unit.py +++ b/geonode/thumbs/tests/test_unit.py @@ -248,7 +248,7 @@ def test_datasets_locations_simple_map(self): ) def test_datasets_locations_simple_map_default_bbox(self): - expected_bbox = [-8238681.374829309, -8220320.783295829, 4969844.0930337105, 4984363.884452854, "EPSG:3857"] + expected_bbox = [-20037397.023298446, 20037397.023298446, -20048966.104014594, 20048966.104014594, "EPSG:3857"] dataset = Dataset.objects.get(title_en="theaters_nyc") map = Map.objects.get(title_en="theaters_nyc_map") diff --git a/geonode/thumbs/thumbnails.py b/geonode/thumbs/thumbnails.py index b6eccbcbe77..f5b03859c7f 100644 --- a/geonode/thumbs/thumbnails.py +++ b/geonode/thumbs/thumbnails.py @@ -34,6 +34,7 @@ from geonode.geoserver.helpers import ogc_server_settings from geonode.utils import get_dataset_name, get_dataset_workspace from geonode.thumbs import utils +from geonode.base import bbox_utils from geonode.thumbs.exceptions import ThumbnailError logger = logging.getLogger(__name__) @@ -110,10 +111,12 @@ def create_thumbnail( if isinstance(instance, Map): is_map_with_datasets = MapLayer.objects.filter(map=instance, local=True).exclude(dataset=None).exists() + if is_map_with_datasets: + compute_bbox_from_datasets = True if bbox: - bbox = utils.clean_bbox(bbox, target_crs) + bbox = bbox_utils.clean_bbox(bbox, target_crs) elif instance.ll_bbox_polygon: - bbox = utils.clean_bbox(instance.ll_bbox, target_crs) + bbox = bbox_utils.clean_bbox(instance.ll_bbox, target_crs) else: compute_bbox_from_datasets = True @@ -268,16 +271,16 @@ def _datasets_locations( locations.append([instance.ows_url or ogc_server_settings.LOCATION, [instance.alternate], []]) if compute_bbox: if instance.ll_bbox_polygon: - bbox = utils.clean_bbox(instance.ll_bbox, target_crs) + bbox = bbox_utils.clean_bbox(instance.ll_bbox, target_crs) elif ( instance.bbox[-1].upper() != "EPSG:3857" and target_crs.upper() == "EPSG:3857" - and utils.exceeds_epsg3857_area_of_use(instance.bbox) + and bbox_utils.exceeds_epsg3857_area_of_use(instance.bbox) ): # handle exceeding the area of use of the default thumb's CRS - bbox = utils.transform_bbox(utils.crop_to_3857_area_of_use(instance.bbox), target_crs) + bbox = bbox_utils.transform_bbox(bbox_utils.crop_to_3857_area_of_use(instance.bbox), target_crs) else: - bbox = utils.transform_bbox(instance.bbox, target_crs) + bbox = bbox_utils.transform_bbox(instance.bbox, target_crs) elif isinstance(instance, Map): for maplayer in instance.maplayers.filter(visibility=True).order_by("order").iterator(): if maplayer.dataset and maplayer.dataset.sourcetype == SOURCE_TYPE_REMOTE and not maplayer.dataset.ows_url: @@ -335,29 +338,9 @@ def _datasets_locations( ] ) - if compute_bbox: - if dataset.ll_bbox_polygon: - dataset_bbox = utils.clean_bbox(dataset.ll_bbox, target_crs) - elif ( - dataset.bbox[-1].upper() != "EPSG:3857" - and target_crs.upper() == "EPSG:3857" - and utils.exceeds_epsg3857_area_of_use(dataset.bbox) - ): - # handle exceeding the area of use of the default thumb's CRS - dataset_bbox = utils.transform_bbox(utils.crop_to_3857_area_of_use(dataset.bbox), target_crs) - else: - dataset_bbox = utils.transform_bbox(dataset.bbox, target_crs) - - if not bbox: - bbox = dataset_bbox - else: - # dataset's BBOX: (left, right, bottom, top) - bbox = [ - min(bbox[0], dataset_bbox[0]), - max(bbox[1], dataset_bbox[1]), - min(bbox[2], dataset_bbox[2]), - max(bbox[3], dataset_bbox[3]), - ] + if compute_bbox: + instance.compute_bbox(target_crs) + bbox = instance.bbox if bbox and len(bbox) < 5: bbox = list(bbox) + [target_crs] # convert bbox to list, if it's tuple diff --git a/geonode/thumbs/utils.py b/geonode/thumbs/utils.py index 9c07003b05f..fa360e3f50b 100644 --- a/geonode/thumbs/utils.py +++ b/geonode/thumbs/utils.py @@ -17,13 +17,11 @@ # ######################################################################### import os -import re import time import base64 import logging from PIL import Image, ImageOps -from pyproj import CRS from typing import List, Tuple, Callable, Union from uuid import uuid4 from urllib.parse import urlencode @@ -31,7 +29,6 @@ from django.conf import settings from django.contrib.auth import get_user_model -from geonode.utils import bbox_to_projection from geonode.base.auth import get_or_create_token from geonode.thumbs.exceptions import ThumbnailError from geonode.storage.manager import storage_manager @@ -72,16 +69,6 @@ def make_bbox_to_pixels_transf(src_bbox: Union[List, Tuple], dest_bbox: Union[Li ) -def transform_bbox(bbox: List, target_crs: str = "EPSG:3857"): - """ - Function transforming BBOX in dataset compliant format (xmin, xmax, ymin, ymax, 'EPSG:xxxx') to another CRS, - preserving overflow values. - """ - match = re.match(r"^(EPSG:)?(?P\d{4,6})$", str(target_crs)) - target_srid = int(match.group("srid")) if match else 4326 - return list(bbox_to_projection(bbox, target_srid=target_srid))[:-1] + [target_crs] - - def expand_bbox_to_ratio( bbox: List, target_width: int = settings.THUMBNAIL_SIZE["width"], @@ -438,82 +425,6 @@ def getmap( return u -def epsg_3857_area_of_use(): - """ - Shortcut function, returning area of use of EPSG:3857 (in EPSG:4326) in a dataset compliant BBOX - """ - epsg3857 = CRS.from_user_input("EPSG:3857") - return [ - getattr(epsg3857.area_of_use, "west"), - getattr(epsg3857.area_of_use, "east"), - getattr(epsg3857.area_of_use, "south"), - getattr(epsg3857.area_of_use, "north"), - "EPSG:4326", - ] - - -def crop_to_3857_area_of_use(bbox: List) -> List: - # perform the comparison in EPSG:4326 (the pivot for EPSG:3857) - bbox4326 = transform_bbox(bbox, target_crs="EPSG:4326") - - # get area of use of EPSG:3857 in EPSG:4326 - epsg3857_bounds_bbox = epsg_3857_area_of_use() - - bbox = [] - for coord, bound_coord in zip(bbox4326[:-1], epsg3857_bounds_bbox[:-1]): - if abs(coord) > abs(bound_coord): - logger.debug("Thumbnail generation: cropping BBOX's coord to EPSG:3857 area of use.") - bbox.append(bound_coord) - else: - bbox.append(coord) - - bbox.append("EPSG:4326") - - return bbox - - -def exceeds_epsg3857_area_of_use(bbox: List) -> bool: - """ - Function checking if a provided BBOX extends the are of use of EPSG:3857. Comparison is performed after casting - the BBOX to EPSG:4326 (pivot for EPSG:3857). - - :param bbox: a dataset compliant BBOX in a certain CRS, in (xmin, xmax, ymin, ymax, 'EPSG:xxxx') order - :returns: List of indicators whether BBOX's coord exceeds the area of use of EPSG:3857 - """ - - # perform the comparison in EPSG:4326 (the pivot for EPSG:3857) - bbox4326 = transform_bbox(bbox, target_crs="EPSG:4326") - - # get area of use of EPSG:3857 in EPSG:4326 - epsg3857_bounds_bbox = epsg_3857_area_of_use() - - exceeds = False - for coord, bound_coord in zip(bbox4326[:-1], epsg3857_bounds_bbox[:-1]): - if abs(coord) > abs(bound_coord): - exceeds = True - - return exceeds - - -def clean_bbox(bbox, target_crs): - # make sure BBOX is provided with the CRS in a correct format - source_crs = bbox[-1] - - srid_regex = re.match(r"EPSG:\d+", source_crs) - if not srid_regex: - logger.error(f"Thumbnail bbox is in a wrong format: {bbox}") - raise ThumbnailError("Wrong BBOX format") - - # for the EPSG:3857 (default thumb's CRS) - make sure received BBOX can be transformed to the target CRS; - # if it can't be (original coords are outside of the area of use of EPSG:3857), thumbnail generation with - # the provided bbox is impossible. - if target_crs == "EPSG:3857" and bbox[-1].upper() != "EPSG:3857": - bbox = crop_to_3857_area_of_use(bbox) - - bbox = transform_bbox(bbox, target_crs=target_crs) - return bbox - - def thumb_path(filename): """Return the complete path of the provided thumbnail file accessible via Django storage API"""