diff --git a/pystac/extensions/virtual_assets.py b/pystac/extensions/virtual_assets.py new file mode 100644 index 000000000..8d3f729c1 --- /dev/null +++ b/pystac/extensions/virtual_assets.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import ( + Any, + Dict, + Iterable, + List, + Optional, + TypeVar, + Union, +) +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension +) +import pystac +S = TypeVar("S", bound=pystac.STACObject) + +PREFIX = "virtual:" +HREFS_PROP = PREFIX + "hrefs" +SCHEMA_URI = "https://github.com/stac-extensions/virtual-assets/blob/main/json-schema/schema.json" + +class VirtualAssetsExtension( + PropertiesExtension, ExtensionManagementMixin[Union[pystac.Item, pystac.Collection]] +): + """A class that can be used to extend the properties of a :class:`~pystac.Asset` + with properties from the :stac-ext:`Virtual Assets Extension `. + + To create an instance of :class:`VirtualAssetsExtension`, use the + :meth:`VirtualAssetsExtension.ext` method. For example: + + .. code-block:: python + + >>> asset: pystac.Asset = ... + >>> virtual_assets_ext = VirtualAssetsExtension.ext(asset) + """ + asset_href: str + """The ``href`` value of the :class:`~pystac.Asset` being extended.""" + + properties: Dict[str, Any] + """The :class:`~pystac.Asset` fields, including extension properties.""" + + additional_read_properties: Optional[Iterable[Dict[str, Any]]] = None + """If present, this will be a list containing 1 dictionary representing the + properties of the owning :class:`~pystac.Item`.""" + + def __init__(self, asset: pystac.Asset): + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + + def __repr__(self) -> str: + return "".format(self.asset_href) + + def apply( + self, + hrefs: List[str] + ) -> None: + """Applies virtual assets extension properties to the extended Item. + + Args: + hrefs : List of hrefs to virtual assets. + """ + self.hrefs = hrefs + + @property + def hrefs(self) -> List[str]: + """Get or sets the hrefs of the virtual asset.""" + return self._get_property(HREFS_PROP, int) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + # TODO: remove this once extension is released. + def has_extension(cls, obj: S) -> bool: + """Check if the given object implements this extension by checking + :attr:`pystac.STACObject.stac_extensions` for this extension's schema URI.""" + schema_uri = cls.get_schema_uri() + + return obj.stac_extensions is not None and any( + uri == schema_uri for uri in obj.stac_extensions + ) + + @classmethod + def ext(cls, obj: pystac.Asset, add_if_missing: bool = False) -> VirtualAssetsExtension: + """Extends the given STAC Object with properties from the :stac-ext:`Virtual Asset + Extension `. + + This extension can be applied to instances of :class:`~pystac.Asset`. + """ + if isinstance(obj, pystac.Asset): + cls.validate_owner_has_extension(obj, add_if_missing) + return cls(obj) + else: + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) \ No newline at end of file diff --git a/tests/data-files/virtual-assets/sentinel-item.json b/tests/data-files/virtual-assets/sentinel-item.json new file mode 100644 index 000000000..6d6ee9c3a --- /dev/null +++ b/tests/data-files/virtual-assets/sentinel-item.json @@ -0,0 +1,752 @@ +{ + "type": "Feature", + "stac_version": "1.0.0-rc.4", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/view/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json", + "https://stac-extensions.github.io/raster/v1.0.0/schema.json", + "https://github.com/stac-extensions/virtual-assets/blob/main/json-schema/schema.json" + ], + "id": "S2B_33SVB_20210221_0_L2A", + "bbox": [ + 13.86148243891681, + 36.95257399124932, + 15.111074610520053, + 37.94752813015372 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 13.876381589019879, + 36.95257399124932 + ], + [ + 13.86148243891681, + 37.942072015005024 + ], + [ + 15.111074610520053, + 37.94752813015372 + ], + [ + 15.109620666835209, + 36.95783951241028 + ], + [ + 13.876381589019879, + 36.95257399124932 + ] + ] + ] + }, + "properties": { + "datetime": "2021-02-21T10:00:17Z", + "platform": "sentinel-2b", + "constellation": "sentinel-2", + "instruments": [ + "msi" + ], + "gsd": 10, + "view:off_nadir": 0, + "proj:epsg": 32633, + "sentinel:utm_zone": 33, + "sentinel:latitude_band": "S", + "sentinel:grid_square": "VB", + "sentinel:sequence": "0", + "sentinel:product_id": "S2B_MSIL2A_20210221T095029_N0214_R079_T33SVB_20210221T115149", + "sentinel:data_coverage": 100, + "eo:cloud_cover": 21.22, + "sentinel:valid_cloud_cover": true + }, + "collection": "sentinel-s2-l2a-cogs", + "assets": { + "thumbnail": { + "title": "Thumbnail", + "type": "image/png", + "roles": [ + "thumbnail" + ], + "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/33/S/VB/2021/2/21/0/preview.jpg" + }, + "overview": { + "title": "True color image", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "overview" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B04", + "common_name": "red", + "center_wavelength": 0.6645, + "full_width_half_max": 0.038 + }, + { + "name": "B03", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.045 + }, + { + "name": "B02", + "common_name": "blue", + "center_wavelength": 0.4966, + "full_width_half_max": 0.098 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/L2A_PVI.tif", + "proj:shape": [ + 343, + 343 + ], + "proj:transform": [ + 320, + 0, + 399960, + 0, + -320, + 4200000, + 0, + 0, + 1 + ] + }, + "info": { + "title": "Original JSON metadata", + "type": "application/json", + "roles": [ + "metadata" + ], + "href": "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/33/S/VB/2021/2/21/0/tileInfo.json" + }, + "metadata": { + "title": "Original XML metadata", + "type": "application/xml", + "roles": [ + "metadata" + ], + "href": "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/33/S/VB/2021/2/21/0/metadata.xml" + }, + "visual": { + "title": "True color image", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "overview" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B04", + "common_name": "red", + "center_wavelength": 0.6645, + "full_width_half_max": 0.038 + }, + { + "name": "B03", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.045 + }, + { + "name": "B02", + "common_name": "blue", + "center_wavelength": 0.4966, + "full_width_half_max": 0.098 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/TCI.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ] + }, + "B01": { + "title": "Band 1 (coastal)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 60, + "eo:bands": [ + { + "name": "B01", + "common_name": "coastal", + "center_wavelength": 0.4439, + "full_width_half_max": 0.027 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B01.tif", + "proj:shape": [ + 1830, + 1830 + ], + "proj:transform": [ + 60, + 0, + 399960, + 0, + -60, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 20567, + "stats_mean": 2339.4759595597, + "stats_stddev": 3026.6973619954, + "stats_valid_percent": 99.83, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B02": { + "title": "Band 2 (blue)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B02", + "common_name": "blue", + "center_wavelength": 0.4966, + "full_width_half_max": 0.098 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B02.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 19264, + "stats_mean": 2348.069117847, + "stats_stddev": 2916.5446249911, + "stats_valid_percent": 99.99, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B03": { + "title": "Band 3 (green)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B03", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.045 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B03.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 18064, + "stats_mean": 2384.4680007438, + "stats_stddev": 2675.410513295, + "stats_valid_percent": 99.999, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B04": { + "title": "Band 4 (red)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B04", + "common_name": "red", + "center_wavelength": 0.6645, + "full_width_half_max": 0.038 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B04.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 17200, + "stats_mean": 2273.9667970732, + "stats_stddev": 2618.272802792, + "stats_valid_percent": 99.999, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B05": { + "title": "Band 5", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B05", + "center_wavelength": 0.7039, + "full_width_half_max": 0.019 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B05.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 16842, + "stats_mean": 2634.1490243416, + "stats_stddev": 2634.1490243416, + "stats_valid_percent": 99.999, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B06": { + "title": "Band 6", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B06", + "center_wavelength": 0.7402, + "full_width_half_max": 0.018 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B06.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ], + "raster:bands": [ + { + "data_type": "uint16", + "nodata": 0, + "stats_min": 1, + "stats_max": 16502, + "stats_mean": 3329.8844628619, + "stats_stddev": 2303.0096294469, + "stats_valid_percent": 99.999, + "values": [ + { + "name": "BOA reflectance", + "unit": "W/m²/sr/μm" + } + ] + } + ] + }, + "B07": { + "title": "Band 7", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B07", + "center_wavelength": 0.7825, + "full_width_half_max": 0.028 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B07.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ] + }, + "B08": { + "title": "Band 8 (nir)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 10, + "eo:bands": [ + { + "name": "B08", + "common_name": "nir", + "center_wavelength": 0.8351, + "full_width_half_max": 0.145 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B08.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ] + }, + "B8A": { + "title": "Band 8A", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B8A", + "center_wavelength": 0.8648, + "full_width_half_max": 0.033 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B8A.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ] + }, + "B09": { + "title": "Band 9", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 60, + "eo:bands": [ + { + "name": "B09", + "center_wavelength": 0.945, + "full_width_half_max": 0.026 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B09.tif", + "proj:shape": [ + 1830, + 1830 + ], + "proj:transform": [ + 60, + 0, + 399960, + 0, + -60, + 4200000, + 0, + 0, + 1 + ] + }, + "B11": { + "title": "Band 11 (swir16)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B11", + "common_name": "swir16", + "center_wavelength": 1.6137, + "full_width_half_max": 0.143 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B11.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ] + }, + "B12": { + "title": "Band 12 (swir22)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "gsd": 20, + "eo:bands": [ + { + "name": "B12", + "common_name": "swir22", + "center_wavelength": 2.22024, + "full_width_half_max": 0.242 + } + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/B12.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ] + }, + "AOT": { + "title": "Aerosol Optical Thickness (AOT)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/AOT.tif", + "proj:shape": [ + 1830, + 1830 + ], + "proj:transform": [ + 60, + 0, + 399960, + 0, + -60, + 4200000, + 0, + 0, + 1 + ] + }, + "WVP": { + "title": "Water Vapour (WVP)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/WVP.tif", + "proj:shape": [ + 10980, + 10980 + ], + "proj:transform": [ + 10, + 0, + 399960, + 0, + -10, + 4200000, + 0, + 0, + 1 + ] + }, + "SCL": { + "title": "Scene Classification Map (SCL)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/33/S/VB/2021/2/S2B_33SVB_20210221_0_L2A/SCL.tif", + "proj:shape": [ + 5490, + 5490 + ], + "proj:transform": [ + 20, + 0, + 399960, + 0, + -20, + 4200000, + 0, + 0, + 1 + ] + }, + "sir": { + "title": "Shortwave Infra-red", + "href": "https://maps.example.com/service/compose.tif?assets=B12,B8A,B11", + "virtual:hrefs": [ + "#B12", + "#B8A", + "#B04" + ], + "roles": [ + "sir", + "virtual" + ], + "gsd": 10, + "type": "image/tiff; application=geotiff; profile=cloud-optimized" + } + }, + "links": [ + { + "rel": "collection", + "href": "./collection.json", + "type": "application/json", + "title": "Sentinel-2 L2A Cogs Collection" + } + ] + } \ No newline at end of file diff --git a/tests/extensions/test_virtual_assets.py b/tests/extensions/test_virtual_assets.py new file mode 100644 index 000000000..4adbdd24a --- /dev/null +++ b/tests/extensions/test_virtual_assets.py @@ -0,0 +1,36 @@ +import json +import logging +from copy import deepcopy +from datetime import datetime +from typing import Any, Dict, List, cast, TypeVar + +import pytest +from dateutil.parser import parse + +from pystac import Item +from pystac.extensions.virtual_assets import VirtualAssetsExtension +from tests.utils import TestCases + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger() + +SENTINEL_ITEM_EXAMPLE_URI = TestCases.get_path( + "data-files/virtual-assets/sentinel-item.json" +) + +@pytest.fixture +def item_dict() -> Dict[str, Any]: + with open(SENTINEL_ITEM_EXAMPLE_URI) as f: + return cast(Dict[str, Any], json.load(f)) + +@pytest.fixture +def sentinel_item() -> Item: + return Item.from_file(SENTINEL_ITEM_EXAMPLE_URI) + +def test_stac_extensions(sentinel_item: Item) -> None: + assert VirtualAssetsExtension.has_extension(sentinel_item) + +def test_has_hrefs(sentinel_item: Item) -> None: + asset = sentinel_item.assets['sir'] + assert VirtualAssetsExtension.ext(asset).hrefs == ['#B12', '#B8A', '#B04'] +