diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb817db2..eda9cd156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Top-level `item_assets` dict on `Collection`s ([#1476](https://github.com/stac-utils/pystac/pull/1476)) +- Render Extension ([#1465](https://github.com/stac-utils/pystac/pull/1465)) ### Changed diff --git a/pystac/extensions/ext.py b/pystac/extensions/ext.py index e86e9d0aa..9da6b8d4d 100644 --- a/pystac/extensions/ext.py +++ b/pystac/extensions/ext.py @@ -20,6 +20,7 @@ from pystac.extensions.pointcloud import PointcloudExtension from pystac.extensions.projection import ProjectionExtension from pystac.extensions.raster import RasterExtension +from pystac.extensions.render import Render, RenderExtension from pystac.extensions.sar import SarExtension from pystac.extensions.sat import SatExtension from pystac.extensions.scientific import ScientificExtension @@ -44,6 +45,7 @@ "pc", "proj", "raster", + "render", "sar", "sat", "sci", @@ -66,6 +68,7 @@ PointcloudExtension.name: PointcloudExtension, ProjectionExtension.name: ProjectionExtension, RasterExtension.name: RasterExtension, + RenderExtension.name: RenderExtension, SarExtension.name: SarExtension, SatExtension.name: SatExtension, ScientificExtension.name: ScientificExtension, @@ -118,6 +121,10 @@ def cube(self) -> DatacubeExtension[Collection]: def item_assets(self) -> dict[str, ItemAssetDefinition]: return ItemAssetsExtension.ext(self.stac_object).item_assets + @property + def render(self) -> dict[str, Render]: + return RenderExtension.ext(self.stac_object).renders + @property def sci(self) -> ScientificExtension[Collection]: return ScientificExtension.ext(self.stac_object) @@ -172,6 +179,10 @@ def pc(self) -> PointcloudExtension[Item]: def proj(self) -> ProjectionExtension[Item]: return ProjectionExtension.ext(self.stac_object) + @property + def render(self) -> RenderExtension[Item]: + return RenderExtension.ext(self.stac_object) + @property def sar(self) -> SarExtension[Item]: return SarExtension.ext(self.stac_object) diff --git a/pystac/extensions/render.py b/pystac/extensions/render.py new file mode 100644 index 000000000..3f5f5f51a --- /dev/null +++ b/pystac/extensions/render.py @@ -0,0 +1,428 @@ +"""Implements the :stac-ext:`Render Extension `.""" + +from __future__ import annotations + +from typing import Any, Generic, Literal, TypeVar + +import pystac +from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension +from pystac.extensions.hooks import ExtensionHooks +from pystac.utils import get_required, map_opt + +T = TypeVar("T", pystac.Collection, pystac.Item) + +SCHEMA_URI_PATTERN: str = ( + "https://stac-extensions.github.io/render/v{version}/schema.json" +) +DEFAULT_VERSION: str = "2.0.0" + +SUPPORTED_VERSIONS = [DEFAULT_VERSION] + +RENDERS_PROP = "renders" + + +class Render: + """Parameters for creating a rendered view of assets.""" + + properties: dict[str, Any] + + def __init__(self, properties: dict[str, Any]) -> None: + self.properties = properties + + @property + def assets(self) -> list[str]: + """ + List of asset keys referencing the assets that are + used to make the rendering. + """ + return get_required(self.properties.get("assets"), self, "assets") + + @assets.setter + def assets(self, v: list[str]) -> None: + self.properties["assets"] = v + + @property + def title(self) -> str | None: + """Title of the rendering""" + return self.properties.get("title") + + @title.setter + def title(self, v: str | None) -> None: + if v is not None: + self.properties["title"] = v + else: + self.properties.pop("title", None) + + @property + def rescale(self) -> list[list[float]] | None: + """A list of min/max value pairs to rescale each asset by, e.g. + `[[0, 5000], [0, 7000], [0, 9000]]`. If not provided, the + assets will not be rescaled. + """ + return self.properties.get("rescale") + + @rescale.setter + def rescale(self, v: list[list[float]] | None) -> None: + if v is not None: + self.properties["rescale"] = v + else: + self.properties.pop("rescale", None) + + @property + def nodata(self) -> float | str | None: + """Nodata value.""" + return self.properties.get("nodata") + + @nodata.setter + def nodata(self, v: float | str | None) -> None: + if v is not None: + self.properties["nodata"] = v + else: + self.properties.pop("nodata", None) + + @property + def colormap_name(self) -> str | None: + """Name of color map to apply to the render. + See: https://matplotlib.org/stable/gallery/color/colormap_reference.html + """ + return self.properties.get("colormap_name") + + @colormap_name.setter + def colormap_name(self, v: str | None) -> None: + if v is not None: + self.properties["colormap_name"] = v + else: + self.properties.pop("colormap_name", None) + + @property + def colormap(self) -> dict[str, Any] | None: + """A dictionary containing a custom colormap definition. + See: https://developmentseed.org/titiler/advanced/rendering/#custom-colormaps + """ + return self.properties.get("colormap") + + @colormap.setter + def colormap(self, v: dict[str, Any] | None) -> None: + if v is not None: + self.properties["colormap"] = v + else: + self.properties.pop("colormap", None) + + @property + def color_formula(self) -> str | None: + """A string containing a color formula to apply + color corrections to images. Useful for reducing + artefacts like atmospheric haze, dark shadows, or + muted colors. + See: https://developmentseed.org/titiler/advanced/rendering/#color-formula + """ + return self.properties.get("color_formula") + + @color_formula.setter + def color_formula(self, v: str | None) -> None: + if v is not None: + self.properties["color_formula"] = v + else: + self.properties.pop("color_formula", None) + + @property + def resampling(self) -> str | None: + """Resampling algorithm to apply to the referenced assets. See GDAL + resampling algorithm for some examples. + See: https://gdal.org/en/latest/programs/gdalwarp.html#cmdoption-gdalwarp-r + """ + return self.properties.get("resampling") + + @resampling.setter + def resampling(self, v: str | None) -> None: + if v is not None: + self.properties["resampling"] = v + else: + self.properties.pop("resampling", None) + + @property + def expression(self) -> str | None: + """Band arithmetic formula to apply to the referenced assets.""" + return self.properties.get("expression") + + @expression.setter + def expression(self, v: str | None) -> None: + if v is not None: + self.properties["expression"] = v + else: + self.properties.pop("expression", None) + + @property + def minmax_zoom(self) -> list[int] | None: + """Zoom level range applicable for the visualization, e.g. `[2, 18]`.""" + return self.properties.get("minmax_zoom") + + @minmax_zoom.setter + def minmax_zoom(self, v: list[int] | None) -> None: + if v is not None: + self.properties["minmax_zoom"] = v + else: + self.properties.pop("minmax_zoom", None) + + def apply( + self, + assets: list[str], + title: str | None = None, + rescale: list[list[float]] | None = None, + nodata: float | str | None = None, + colormap_name: str | None = None, + colormap: dict[str, Any] | None = None, + color_formula: str | None = None, + resampling: str | None = None, + expression: str | None = None, + minmax_zoom: list[int] | None = None, + ) -> None: + """Set the properties for a new Render. + + Args: + assets: + List of asset keys referencing the assets that are + used to make the rendering. + title: + Title of the rendering. + rescale: + A list of min/max value pairs to rescale each asset by, e.g. + `[[0, 5000], [0, 7000], [0, 9000]]`. If not provided, the + assets will not be rescaled. + nodata: + Nodata value. + colormap_name: + Name of color map to apply to the render. + https://matplotlib.org/stable/gallery/color/colormap_reference.html + colormap: + A dictionary containing a custom colormap definition. + https://developmentseed.org/titiler/advanced/rendering/#custom-colormaps + color_formula: + A string containing a color formula to apply + color corrections to images. Useful for reducing + artefacts like atmospheric haze, dark shadows, or + muted colors. + https://developmentseed.org/titiler/advanced/rendering/#color-formula + resampling: + Resampling algorithm to apply to the referenced assets. See GDAL + resampling algorithm for some examples. + https://gdal.org/en/latest/programs/gdalwarp.html#cmdoption-gdalwarp-r + expression: + Band arithmetic formula to apply to the referenced assets. + minmax_zoom: + Zoom level range applicable for the visualization, e.g. `[2, 18]`. + """ + self.assets = assets + self.title = title + self.rescale = rescale + self.nodata = nodata + self.colormap_name = colormap_name + self.colormap = colormap + self.color_formula = color_formula + self.resampling = resampling + self.expression = expression + self.minmax_zoom = minmax_zoom + + @classmethod + def create( + cls, + assets: list[str], + title: str | None = None, + rescale: list[list[float]] | None = None, + nodata: float | str | None = None, + colormap_name: str | None = None, + colormap: dict[str, Any] | None = None, + color_formula: str | None = None, + resampling: str | None = None, + expression: str | None = None, + minmax_zoom: list[int] | None = None, + ) -> Render: + """Create a new Render. + + Args: + assets: + List of asset keys referencing the assets that are + used to make the rendering. + title: + Title of the rendering. + rescale: + A list of min/max value pairs to rescale each asset by, e.g. + `[[0, 5000], [0, 7000], [0, 9000]]`. If not provided, the + assets will not be rescaled. + nodata: + Nodata value. + colormap_name: + Name of color map to apply to the render. + https://matplotlib.org/stable/gallery/color/colormap_reference.html + colormap: + A dictionary containing a custom colormap definition. + https://developmentseed.org/titiler/advanced/rendering/#custom-colormaps + color_formula: + A string containing a color formula to apply + color corrections to images. Useful for reducing + artefacts like atmospheric haze, dark shadows, or + muted colors. + https://developmentseed.org/titiler/advanced/rendering/#color-formula + resampling: + Resampling algorithm to apply to the referenced assets. See GDAL + resampling algorithm for some examples. + https://gdal.org/en/latest/programs/gdalwarp.html#cmdoption-gdalwarp-r + expression: + Band arithmetic formula to apply to the referenced assets. + minmax_zoom: + Zoom level range applicable for the visualization, e.g. `[2, 18]`. + """ + c = cls({}) + c.apply( + assets=assets, + title=title, + rescale=rescale, + nodata=nodata, + colormap_name=colormap_name, + colormap=colormap, + color_formula=color_formula, + resampling=resampling, + expression=expression, + minmax_zoom=minmax_zoom, + ) + return c + + def to_dict(self) -> dict[str, Any]: + return self.properties + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Render): + raise NotImplementedError + return self.properties == other.properties + + def __repr__(self) -> str: + props = " ".join( + [ + f"{key}={value}" + for key, value in self.properties.items() + if value is not None + ] + ) + return f"" + + +class RenderExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """An abstract class that can be used to extend the properties of a + :class:`~pystac.Collection` or :class:`~pystac.Item` with + properties from the :stac-ext:`Render Extension `. This class is + generic over the type of STAC Object to be extended (e.g. :class:`~pystac.Item`, + :class:`~pystac.Collection`). + + To create a concrete instance of :class:`RenderExtension`, use the + :meth:`RenderExtension.ext` method. For example: + + .. code-block:: python + + >>> item: pystac.Item = ... + >>> xr_ext = RenderExtension.ext(item) + + """ + + name: Literal["render"] = "render" + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI_PATTERN.format(version=DEFAULT_VERSION) + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> RenderExtension[T]: + """Extend the given STAC Object with properties from the + :stac-ext:`Render Extension `. + + This extension can be applied to instances of :class:`~pystac.Collection` + or :class:`~pystac.Item`. + + Raises: + pystac.ExtensionTypeError : If an invalid object type is passed. + """ + if isinstance(obj, pystac.Collection): + cls.ensure_has_extension(obj, add_if_missing) + return CollectionRenderExtension(obj) + elif isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return ItemRenderExtension(obj) + else: + raise pystac.ExtensionTypeError( + f"RenderExtension does not apply to type '{type(obj).__name__}'" + ) + + def apply( + self, + renders: dict[str, Render], + ) -> None: + """Applies the render extension fields to the extended + object. + + Args: + renders: a dictionary mapping render names to + :class: `~pystac.extension.render.Render` objects. + """ + self.renders = renders + + @property + def renders(self) -> dict[str, Render]: + """A dictionary where each key is the name of a render and each + value is a :class:`~Render` object. + """ + renders: dict[str, dict[str, Any]] = get_required( + self._get_property(RENDERS_PROP, dict[str, dict[str, Any]]), + self, + RENDERS_PROP, + ) + return {k: Render(v) for k, v in renders.items()} + + @renders.setter + def renders(self, v: dict[str, Render]) -> None: + self._set_property( + RENDERS_PROP, + map_opt(lambda renders: {k: r.to_dict() for k, r in renders.items()}, v), + pop_if_none=False, + ) + + +class CollectionRenderExtension(RenderExtension[pystac.Collection]): + """A concrete implementation of :class:`RenderExtension` on a + :class:`~pystac.Collection` that extends the properties of the Collection to include + properties defined in the :stac-ext:`Render Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`RenderExtension.ext` on an :class:`~pystac.Collection` to extend it. + """ + + def __init__(self, collection: pystac.Collection): + self.collection = collection + self.properties = collection.extra_fields + + def __repr__(self) -> str: + return f"" + + +class ItemRenderExtension(RenderExtension[pystac.Item]): + """A concrete implementation of :class:`RenderExtension` on a + :class:`~pystac.Item` that extends the properties of the Item to include + properties defined in the :stac-ext:`Render Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`RenderExtension.ext` on an :class:`~pystac.Item` to extend it. + """ + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class RenderExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI_PATTERN.format(version=DEFAULT_VERSION) + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} diff --git a/tests/data-files/render/collection.json b/tests/data-files/render/collection.json new file mode 100644 index 000000000..63ad8a123 --- /dev/null +++ b/tests/data-files/render/collection.json @@ -0,0 +1,42 @@ +{ + "stac_version": "1.1.0", + "stac_extensions": [ + "https://stac-extensions.github.io/render/v2.0.0/schema.json" + ], + "type": "Collection", + "id": "senitnel2-l2a", + "title": "Sentinel-2 L2A", + "description": "Sentinel-2 L2A data", + "license": "Apache-2.0", + "extent": { + "spatial": { + "bbox": [[172.9, 1.3, 173, 1.4]] + }, + "temporal": { + "interval": [["2015-06-23T00:00:00Z", null]] + } + }, + "item_assets": {}, + "renders": { + "ndvi": { + "title": "Normalized Difference Vegetation Index", + "assets": ["ndvi"], + "resampling": "average", + "colormap_name": "ylgn" + } + }, + "summaries": { + "datetime": { + "minimum": "2015-06-23T00:00:00Z", + "maximum": "2019-07-10T13:44:56Z" + } + }, + "links": [ + { + "href": "./collection.json", + "rel": "root", + "title": "Sentinel-2 L2A", + "type": "application/json" + } + ] +} diff --git a/tests/data-files/render/item.json b/tests/data-files/render/item.json new file mode 100644 index 000000000..3c093e9db --- /dev/null +++ b/tests/data-files/render/item.json @@ -0,0 +1,175 @@ +{ + "type": "Feature", + "stac_version": "1.1.0", + "stac_extensions": [ + "https://stac-extensions.github.io/render/v2.0.0/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, + "renders": { + "thumbnail": { + "title": "Thumbnail", + "assets": ["B04", "B03", "B02"], + "rescale": [[0, 150]], + "colormap_name": "rainbow", + "resampling": "bilinear", + "bidx": [1], + "width": 1024, + "height": 1024, + "bands": ["B4", "B3", "B2"] + }, + "sir": { + "title": "Shortwave Infra-red", + "assets": ["B12", "B08", "B04"], + "rescale": [ + [0, 5000], + [0, 7000], + [0, 9000] + ], + "resampling": "nearest" + } + } + }, + "collection": "sentinel-s2-l2a", + "assets": { + "metadata": { + "title": "Original XML metadata", + "type": "application/xml", + "roles": ["metadata"], + "href": "metadata.xml" + }, + "B01": { + "title": "Band 1 (coastal)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 60, + "href": "B01.tif" + }, + "B02": { + "title": "Band 2 (blue)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 10, + "href": "B02.tif" + }, + "B03": { + "title": "Band 3 (green)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 10, + "href": "B03.tif" + }, + "B04": { + "title": "Band 4 (red)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 10, + "href": "B04.tif" + }, + "B05": { + "title": "Band 5", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B05.tif" + }, + "B06": { + "title": "Band 6", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B06.tif" + }, + "B07": { + "title": "Band 7", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B07.tif" + }, + "B08": { + "title": "Band 8 (nir)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 10, + "href": "B08.tif" + }, + "B8A": { + "title": "Band 8A", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B8A.tif" + }, + "B09": { + "title": "Band 9", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 60, + "href": "B09.tif" + }, + "B11": { + "title": "Band 11 (swir16)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B11.tif" + }, + "B12": { + "title": "Band 12 (swir22)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "gsd": 20, + "href": "B12.tif" + }, + "AOT": { + "title": "Aerosol Optical Thickness (AOT)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "href": "AOT.tif" + }, + "WVP": { + "title": "Water Vapour (WVP)", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": ["data"], + "href": "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": "SCL.tif" + } + }, + "links": [ + { + "rel": "collection", + "href": "./collection.json", + "type": "application/json", + "title": "Sentinel-2 L2A Cogs Collection" + } + ] +} diff --git a/tests/extensions/cassettes/test_render/test_collection_validate.yaml b/tests/extensions/cassettes/test_render/test_collection_validate.yaml new file mode 100644 index 000000000..b399f60c4 --- /dev/null +++ b/tests/extensions/cassettes/test_render/test_collection_validate.yaml @@ -0,0 +1,148 @@ +interactions: +- request: + body: null + headers: + Connection: + - close + Host: + - stac-extensions.github.io + User-Agent: + - Python-urllib/3.11 + method: GET + uri: https://stac-extensions.github.io/render/v2.0.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/render/v2.0.0/schema.json#\",\n \"title\": + \"Rendering Extension\",\n \"description\": \"STAC Rendering Extension for + STAC Items and STAC Collections.\",\n \"oneOf\": [\n {\n \"$comment\": + \"This is the schema for STAC Items.\",\n \"allOf\": [\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"type\",\n \"properties\",\n + \ \"assets\"\n ],\n \"properties\": {\n \"type\": + {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"type\": \"object\",\n \"required\": [\"renders\"],\n + \ \"properties\": {\n \"renders\": {\n \"type\": + \"object\",\n \"additionalProperties\": {\n \"$ref\": + \"#/definitions/fields\"\n }\n }\n }\n + \ }\n }\n },\n {\n \"if\": {\n \"properties\": + {\n \"stac_extensions\": {\n \"contains\": {\n + \ \"type\": \"string\",\n \"pattern\": \"https:\\/\\/stac-extensions\\\\.github\\\\.io\\/web-map-links\\/.*\"\n + \ }\n }\n }\n },\n \"then\": + {\n \"properties\": {\n \"links\": {\n \"type\": + \"array\",\n \"contains\": {\n \"type\": \"object\",\n + \ \"required\": [\n \"rel\",\n \"render\"\n + \ ],\n \"properties\": {\n \"render\": + {\n \"type\": \"string\"\n }\n }\n + \ }\n }\n }\n }\n }\n + \ ]\n },\n {\n \"$comment\": \"This is the schema for STAC + Collections.\",\n \"type\": \"object\",\n \"allOf\": [\n {\n + \ \"required\": [\n \"type\",\n \"renders\"\n + \ ],\n \"properties\": {\n \"type\": {\n \"const\": + \"Collection\"\n }\n }\n },\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n }\n ],\n \"anyOf\": [\n + \ {\n \"$comment\": \"This validates the fields in Collection + Assets, but does not require them.\",\n \"anyOf\": [\n {\n + \ \"type\": \"object\",\n \"required\": [\n \"assets\"\n + \ ]\n },\n {\n \"type\": \"object\",\n + \ \"required\": [\n \"item_assets\"\n ]\n + \ }\n ],\n \"properties\": {\n \"renders\": + {\n \"type\": \"object\",\n \"not\": {\n \"additionalProperties\": + {\n \"not\": {\n \"allOf\": [\n {\n + \ \"$ref\": \"#/definitions/require_any_field\"\n },\n + \ {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n + \ }\n }\n }\n }\n ]\n }\n ],\n + \ \"definitions\": {\n \"stac_extensions\": {\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": + {\n \"stac_extensions\": {\n \"type\": \"array\",\n \"contains\": + {\n \"const\": \"https://stac-extensions.github.io/render/v2.0.0/schema.json\"\n + \ }\n }\n }\n },\n \"require_any_field\": {\n \"$comment\": + \"Please list all fields here so that we can force the existence of one of + them in other parts of the schemas.\",\n \"anyOf\": [\n {\n \"type\": + \"object\",\n \"required\": [\n \"assets\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"title\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"rescale\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"nodata\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"colormap_name\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"colormap\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"color_formula\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"resampling\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"expression\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"minmax_zoom\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"bidx\"\n ]\n + \ }\n ]\n },\n \"fields\": {\n \"$comment\": \"Add your + new fields here. Don't require them here, do that above in the corresponding + schema.\",\n \"type\": \"object\",\n \"required\": [\n \"assets\"\n + \ ],\n \"properties\": {\n \"assets\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n + \ },\n \"title\": {\n \"type\": \"string\"\n },\n + \ \"rescale\": {\n \"type\": \"array\",\n \"items\": + {\n \"type\": \"array\",\n \"items\": {\n \"type\": + \"number\"\n }\n }\n },\n \"nodata\": {\n + \ \"type\": [\n \"number\",\n \"string\"\n ]\n + \ },\n \"colormap_name\": {\n \"type\": \"string\"\n + \ },\n \"colormap\": {\n \"type\": \"object\"\n },\n + \ \"color_formula\": {\n \"type\": \"string\"\n },\n + \ \"resampling\": {\n \"type\": \"string\"\n },\n \"expression\": + {\n \"type\": [\"string\", \"object\", \"array\"]\n },\n \"minmax_zoom\": + {\n \"type\": \"array\",\n \"items\": {\n \"type\": + \"number\"\n }\n },\n \"bidx\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"number\"\n }\n + \ }\n },\n \"additionalProperties\": true\n }\n }\n}" + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Origin: + - '*' + Age: + - '0' + Cache-Control: + - max-age=600 + Connection: + - close + Content-Length: + - '6280' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 28 Nov 2024 03:25:43 GMT + ETag: + - '"673d1188-1888"' + Last-Modified: + - Tue, 19 Nov 2024 22:30:32 GMT + Server: + - GitHub.com + Strict-Transport-Security: + - max-age=31556952 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish + X-Cache: + - HIT + X-Cache-Hits: + - '1' + X-Fastly-Request-ID: + - 6497809d3a8a49fe470f9c1dd0ded2e99c2e577b + X-GitHub-Request-Id: + - 906C:38FB3D:2EECA5F:3465B76:6747E2B7 + X-Served-By: + - cache-iad-kiad7000156-IAD + X-Timer: + - S1732764344.653589,VS0,VE1 + expires: + - Thu, 28 Nov 2024 03:35:43 GMT + permissions-policy: + - interest-cohort=() + x-proxy-cache: + - MISS + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extensions/cassettes/test_render/test_item_validate.yaml b/tests/extensions/cassettes/test_render/test_item_validate.yaml new file mode 100644 index 000000000..f7dc6f00a --- /dev/null +++ b/tests/extensions/cassettes/test_render/test_item_validate.yaml @@ -0,0 +1,148 @@ +interactions: +- request: + body: null + headers: + Connection: + - close + Host: + - stac-extensions.github.io + User-Agent: + - Python-urllib/3.11 + method: GET + uri: https://stac-extensions.github.io/render/v2.0.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/render/v2.0.0/schema.json#\",\n \"title\": + \"Rendering Extension\",\n \"description\": \"STAC Rendering Extension for + STAC Items and STAC Collections.\",\n \"oneOf\": [\n {\n \"$comment\": + \"This is the schema for STAC Items.\",\n \"allOf\": [\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"type\",\n \"properties\",\n + \ \"assets\"\n ],\n \"properties\": {\n \"type\": + {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"type\": \"object\",\n \"required\": [\"renders\"],\n + \ \"properties\": {\n \"renders\": {\n \"type\": + \"object\",\n \"additionalProperties\": {\n \"$ref\": + \"#/definitions/fields\"\n }\n }\n }\n + \ }\n }\n },\n {\n \"if\": {\n \"properties\": + {\n \"stac_extensions\": {\n \"contains\": {\n + \ \"type\": \"string\",\n \"pattern\": \"https:\\/\\/stac-extensions\\\\.github\\\\.io\\/web-map-links\\/.*\"\n + \ }\n }\n }\n },\n \"then\": + {\n \"properties\": {\n \"links\": {\n \"type\": + \"array\",\n \"contains\": {\n \"type\": \"object\",\n + \ \"required\": [\n \"rel\",\n \"render\"\n + \ ],\n \"properties\": {\n \"render\": + {\n \"type\": \"string\"\n }\n }\n + \ }\n }\n }\n }\n }\n + \ ]\n },\n {\n \"$comment\": \"This is the schema for STAC + Collections.\",\n \"type\": \"object\",\n \"allOf\": [\n {\n + \ \"required\": [\n \"type\",\n \"renders\"\n + \ ],\n \"properties\": {\n \"type\": {\n \"const\": + \"Collection\"\n }\n }\n },\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n }\n ],\n \"anyOf\": [\n + \ {\n \"$comment\": \"This validates the fields in Collection + Assets, but does not require them.\",\n \"anyOf\": [\n {\n + \ \"type\": \"object\",\n \"required\": [\n \"assets\"\n + \ ]\n },\n {\n \"type\": \"object\",\n + \ \"required\": [\n \"item_assets\"\n ]\n + \ }\n ],\n \"properties\": {\n \"renders\": + {\n \"type\": \"object\",\n \"not\": {\n \"additionalProperties\": + {\n \"not\": {\n \"allOf\": [\n {\n + \ \"$ref\": \"#/definitions/require_any_field\"\n },\n + \ {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n + \ }\n }\n }\n }\n ]\n }\n ],\n + \ \"definitions\": {\n \"stac_extensions\": {\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": + {\n \"stac_extensions\": {\n \"type\": \"array\",\n \"contains\": + {\n \"const\": \"https://stac-extensions.github.io/render/v2.0.0/schema.json\"\n + \ }\n }\n }\n },\n \"require_any_field\": {\n \"$comment\": + \"Please list all fields here so that we can force the existence of one of + them in other parts of the schemas.\",\n \"anyOf\": [\n {\n \"type\": + \"object\",\n \"required\": [\n \"assets\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"title\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"rescale\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"nodata\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"colormap_name\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"colormap\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"color_formula\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"resampling\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"expression\"\n ]\n + \ },\n {\n \"type\": \"object\",\n \"required\": + [\n \"minmax_zoom\"\n ]\n },\n {\n \"type\": + \"object\",\n \"required\": [\n \"bidx\"\n ]\n + \ }\n ]\n },\n \"fields\": {\n \"$comment\": \"Add your + new fields here. Don't require them here, do that above in the corresponding + schema.\",\n \"type\": \"object\",\n \"required\": [\n \"assets\"\n + \ ],\n \"properties\": {\n \"assets\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n + \ },\n \"title\": {\n \"type\": \"string\"\n },\n + \ \"rescale\": {\n \"type\": \"array\",\n \"items\": + {\n \"type\": \"array\",\n \"items\": {\n \"type\": + \"number\"\n }\n }\n },\n \"nodata\": {\n + \ \"type\": [\n \"number\",\n \"string\"\n ]\n + \ },\n \"colormap_name\": {\n \"type\": \"string\"\n + \ },\n \"colormap\": {\n \"type\": \"object\"\n },\n + \ \"color_formula\": {\n \"type\": \"string\"\n },\n + \ \"resampling\": {\n \"type\": \"string\"\n },\n \"expression\": + {\n \"type\": [\"string\", \"object\", \"array\"]\n },\n \"minmax_zoom\": + {\n \"type\": \"array\",\n \"items\": {\n \"type\": + \"number\"\n }\n },\n \"bidx\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"number\"\n }\n + \ }\n },\n \"additionalProperties\": true\n }\n }\n}" + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Origin: + - '*' + Age: + - '0' + Cache-Control: + - max-age=600 + Connection: + - close + Content-Length: + - '6280' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 28 Nov 2024 03:25:43 GMT + ETag: + - '"673d1188-1888"' + Last-Modified: + - Tue, 19 Nov 2024 22:30:32 GMT + Server: + - GitHub.com + Strict-Transport-Security: + - max-age=31556952 + Vary: + - Accept-Encoding + Via: + - 1.1 varnish + X-Cache: + - MISS + X-Cache-Hits: + - '0' + X-Fastly-Request-ID: + - b4ba719d47ef703f07fd2f866070170abe0a35b8 + X-GitHub-Request-Id: + - 906C:38FB3D:2EECA5F:3465B76:6747E2B7 + X-Served-By: + - cache-iad-kiad7000118-IAD + X-Timer: + - S1732764344.565372,VS0,VE10 + expires: + - Thu, 28 Nov 2024 03:35:43 GMT + permissions-policy: + - interest-cohort=() + x-proxy-cache: + - MISS + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extensions/test_render.py b/tests/extensions/test_render.py new file mode 100644 index 000000000..15b90f092 --- /dev/null +++ b/tests/extensions/test_render.py @@ -0,0 +1,234 @@ +"""Tests for pystac.tests.extensions.render""" + +import json + +import pytest + +import pystac +import pystac.errors +from pystac.extensions.render import Render, RenderExtension +from tests.conftest import get_data_file + + +@pytest.fixture +def ext_collection_uri() -> str: + return get_data_file("render/collection.json") + + +@pytest.fixture +def ext_collection(ext_collection_uri: str) -> pystac.Collection: + return pystac.Collection.from_file(ext_collection_uri) + + +@pytest.fixture +def ext_item_uri() -> str: + return get_data_file("render/item.json") + + +@pytest.fixture +def ext_item(ext_item_uri: str) -> pystac.Item: + return pystac.Item.from_file(ext_item_uri) + + +@pytest.fixture +def render() -> Render: + return Render.create( + assets=["B4", "B3", "B2"], + title="RGB", + rescale=[[0, 1000], [0, 1000], [0, 1000]], + nodata=-9999, + colormap_name="viridis", + colormap={ + "0": "#e5f5f9", + "10": "#99d8c9", + "255": "#2ca25f", + }, + color_formula="gamma rg 1.3, sigmoidal rgb 22 0.1, saturation 1.5", + resampling="bilinear", + expression="(B08-B04)/(B08+B04)", + minmax_zoom=[2, 18], + ) + + +@pytest.fixture +def thumbnail_render(ext_item: pystac.Item) -> Render: + return RenderExtension.ext(ext_item).renders["thumbnail"] + + +def test_collection_stac_extensions(ext_collection: pystac.Collection) -> None: + assert RenderExtension.has_extension(ext_collection) + + +def test_item_stac_extensions(ext_item: pystac.Item) -> None: + assert RenderExtension.has_extension(ext_item) + + +def test_ext_raises_if_item_does_not_conform(item: pystac.Item) -> None: + with pytest.raises(pystac.errors.ExtensionNotImplemented): + RenderExtension.ext(item) + + +def test_ext_raises_if_collection_does_not_conform( + collection: pystac.Collection, +) -> None: + with pytest.raises(pystac.errors.ExtensionNotImplemented): + RenderExtension.ext(collection) + + +def test_ext_raises_on_catalog(catalog: pystac.Catalog) -> None: + with pytest.raises( + pystac.errors.ExtensionTypeError, + match="RenderExtension does not apply to type 'Catalog'", + ): + RenderExtension.ext(catalog) # type: ignore + + +def test_item_to_from_dict(ext_item_uri: str, ext_item: pystac.Item) -> None: + with open(ext_item_uri) as f: + d = json.load(f) + actual = ext_item.to_dict(include_self_link=False) + assert actual == d + + +def test_collection_to_from_dict( + ext_collection_uri: str, ext_collection: pystac.Collection +) -> None: + with open(ext_collection_uri) as f: + d = json.load(f) + actual = ext_collection.to_dict(include_self_link=False) + assert actual == d + + +def test_add_to_item(item: pystac.Item) -> None: + assert not RenderExtension.has_extension(item) + RenderExtension.add_to(item) + + assert RenderExtension.has_extension(item) + + +def test_add_to_collection(collection: pystac.Collection) -> None: + assert not RenderExtension.has_extension(collection) + RenderExtension.add_to(collection) + + assert RenderExtension.has_extension(collection) + + +def test_get_render_values(thumbnail_render: Render) -> None: + assert thumbnail_render.title == "Thumbnail" + assert thumbnail_render.assets == ["B04", "B03", "B02"] + assert thumbnail_render.rescale == [[0, 150]] + assert thumbnail_render.colormap_name == "rainbow" + assert thumbnail_render.resampling == "bilinear" + assert thumbnail_render.properties.get("bidx") == [1] + assert thumbnail_render.properties.get("width") == 1024 + assert thumbnail_render.properties.get("height") == 1024 + assert thumbnail_render.properties.get("bands") == ["B4", "B3", "B2"] + + +def test_apply_renders_to_item(item: pystac.Item, render: Render) -> None: + RenderExtension.add_to(item) + + RenderExtension.ext(item).apply({"render": render}) + assert item.ext.render.renders["render"].assets == ["B4", "B3", "B2"] + assert item.ext.render.renders["render"].title == "RGB" + assert item.ext.render.renders["render"].rescale == [ + [0, 1000], + [0, 1000], + [0, 1000], + ] + assert item.ext.render.renders["render"].nodata == -9999 + assert item.ext.render.renders["render"].colormap_name == "viridis" + assert item.ext.render.renders["render"].colormap == { + "0": "#e5f5f9", + "10": "#99d8c9", + "255": "#2ca25f", + } + assert ( + item.ext.render.renders["render"].color_formula + == "gamma rg 1.3, sigmoidal rgb 22 0.1, saturation 1.5" + ) + assert item.ext.render.renders["render"].resampling == "bilinear" + assert item.ext.render.renders["render"].expression == "(B08-B04)/(B08+B04)" + assert item.ext.render.renders["render"].minmax_zoom == [2, 18] + + assert item.ext.render.renders["render"] == render + + +def test_get_unset_properties(item: pystac.Item) -> None: + RenderExtension.add_to(item) + RenderExtension.ext(item).apply( + { + "render": Render.create( + assets=["B4", "B3", "B2"], + ) + } + ) + + assert item.ext.render.renders["render"].title is None + assert item.ext.render.renders["render"].rescale is None + assert item.ext.render.renders["render"].nodata is None + assert item.ext.render.renders["render"].colormap_name is None + assert item.ext.render.renders["render"].colormap is None + assert item.ext.render.renders["render"].color_formula is None + assert item.ext.render.renders["render"].resampling is None + assert item.ext.render.renders["render"].expression is None + assert item.ext.render.renders["render"].minmax_zoom is None + + +def test_equality_check_with_unexpected_type_raises_notimplemented_error() -> None: + render = Render.create( + assets=["B4", "B3", "B2"], + ) + with pytest.raises(NotImplementedError): + _ = render == 1 + + +def test_item_repr(ext_item: pystac.Item) -> None: + ext = RenderExtension.ext(ext_item) + assert ext.__repr__() == f"" + + +def test_collection_repr(ext_collection: pystac.Collection) -> None: + ext = RenderExtension.ext(ext_collection) + assert ( + ext.__repr__() + == f"" + ) + + +def test_render_repr() -> None: + render = Render.create( + assets=["B4", "B3", "B2"], + title="RGB", + rescale=[[0, 1000], [0, 1000], [0, 1000]], + nodata=-9999, + colormap_name="viridis", + colormap={ + "0": "#e5f5f9", + "10": "#99d8c9", + "255": "#2ca25f", + }, + color_formula="gamma rg 1.3, sigmoidal rgb 22 0.1, saturation 1.5", + resampling="bilinear", + expression="(B08-B04)/(B08+B04)", + minmax_zoom=[2, 18], + ) + + assert render.__repr__() == ( + "" + ) + + +@pytest.mark.vcr +def test_item_validate(ext_item: pystac.Item) -> None: + assert ext_item.validate() + + +@pytest.mark.vcr +def test_collection_validate(ext_collection: pystac.Collection) -> None: + assert ext_collection.validate()