diff --git a/CHANGELOG.md b/CHANGELOG.md index 3928b074b..26aa112a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v0.5.3] + +### Added + +- Added support for the pointcloud extension ([#176](https://github.com/stac-utils/pystac/pull/176)) + ## [v0.5.2] Thank you to all the new contributors that contributed during STAC Sprint 6! diff --git a/docs/api.rst b/docs/api.rst index fb90c1e86..ab446dffd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -251,6 +251,20 @@ LabelStatistics :members: :undoc-members: +Pointcloud Extension +-------------------- + +Implements the `Projection Extension `_. + +PointcloudItemExt +~~~~~~~~~~~~~~~~~ + +.. autoclass:: pystac.extensions.projection.PointcloudItemExt + :members: + :undoc-members: + :show-inheritance: + + Projection Extension -------------------- diff --git a/pystac/__init__.py b/pystac/__init__.py index 7fb353452..ef8567ca7 100644 --- a/pystac/__init__.py +++ b/pystac/__init__.py @@ -33,12 +33,14 @@ class STACError(Exception): import pystac.extensions.eo import pystac.extensions.label import pystac.extensions.projection +import pystac.extensions.pointcloud import pystac.extensions.view import pystac.extensions.single_file_stac import pystac.extensions.timestamps STAC_EXTENSIONS = extensions.base.RegisteredSTACExtensions([ extensions.eo.EO_EXTENSION_DEFINITION, extensions.label.LABEL_EXTENSION_DEFINITION, + extensions.pointcloud.POINTCLOUD_EXTENSION_DEFINITION, extensions.projection.PROJECTION_EXTENSION_DEFINITION, extensions.view.VIEW_EXTENSION_DEFINITION, extensions.single_file_stac.SFS_EXTENSION_DEFINITION, extensions.timestamps.TIMESTAMPS_EXTENSION_DEFINITION diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py new file mode 100644 index 000000000..e0c467b51 --- /dev/null +++ b/pystac/extensions/pointcloud.py @@ -0,0 +1,610 @@ +from pystac import Extensions, STACError +from pystac.item import Item +from pystac.extensions.base import (ItemExtension, ExtensionDefinition, ExtendedObject) + + +class PointcloudItemExt(ItemExtension): + """PointcloudItemExt is the extension of an Item in the PointCloud Extension. + The Pointclout extension adds pointcloud information to STAC Items. + + Args: + item (Item): The item to be extended. + + Attributes: + item (Item): The Item that is being extended. + + """ + def __init__(self, item): + if item.stac_extensions is None: + item.stac_extensions = [Extensions.POINTCLOUD] + elif Extensions.POINTCLOUD not in item.stac_extensions: + item.stac_extensions.append(Extensions.POINTCLOUD) + + self.item = item + + def apply(self, count, type, encoding, schemas, density=None, statistics=None, epsg=None): + """Applies Pointcloud extension properties to the extended Item. + + Args: + count (int): REQUIRED. The number of points in the cloud. + type (str): REQUIRED. Phenomenology type for the point cloud. Possible valid + values might include lidar, eopc, radar, sonar, or otherThe type of file + or data format of the cloud. + encoding (str): REQUIRED. Content encoding or format of the data. + schemas (List[dict]): REQUIRED. A sequential array of items that define the + dimensions and their types. + density (dict or None): Number of points per square unit area. + statistics (List[int] or None): A sequential array of items mapping to pc:schemas + defines per-channel statistics. + epsg (str): An EPSG code for the projected coordinates of the pointcloud. + """ + self.count = count + self.type = type + self.encoding = encoding + self.schemas = schemas + self.density = density + self.statistics = statistics + self.epsg = epsg + + @property + def count(self): + """Get or sets the count property of the datasource. + + Returns: + int + """ + return self.get_count() + + @count.setter + def count(self, v): + self.set_count(v) + + def get_count(self, asset=None): + """Gets an Item or an Asset count. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + int + """ + if asset is None or 'pc:count' not in asset.properties: + return self.item.properties.get('pc:count') + else: + return asset.properties.get('pc:count') + + def set_count(self, count, asset=None): + """Set an Item or an Asset count. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if asset is None: + self.item.properties['pc:count'] = count + else: + asset.properties['pc:count'] = count + + @property + def type(self): + """Get or sets the pc:type prop on the Item + + Returns: + str + """ + return self.get_type() + + @type.setter + def type(self, v): + self.set_type(v) + + def get_type(self, asset=None): + """Gets an Item or an Asset type. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + str + """ + if asset is None or 'pc:type' not in asset.properties: + return self.item.properties.get('pc:type') + else: + return asset.properties.get('pc:type') + + def set_type(self, type, asset=None): + """Set an Item or an Asset type. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if asset is None: + self.item.properties['pc:type'] = type + else: + asset.properties['pc:type'] = type + + @property + def encoding(self): + """Get or sets the content-encoding for the item. + + The content-encoding is the underlying encoding format for the point cloud. + Examples may include: laszip, ascii, binary, etc. + + Returns: + str + """ + return self.get_encoding() + + @encoding.setter + def encoding(self, v): + self.set_encoding(v) + + def get_encoding(self, asset=None): + """Gets an Item or an Asset encoding. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + str + """ + if asset is None or 'pc:encoding' not in asset.properties: + return self.item.properties.get('pc:encoding') + else: + return asset.properties.get('pc:encoding') + + def set_encoding(self, encoding, asset=None): + """Set an Item or an Asset encoding. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if asset is None: + self.item.properties['pc:encoding'] = encoding + else: + asset.properties['pc:encoding'] = encoding + + @property + def schemas(self): + """Get or sets a + + The schemas represent the structure of the data attributes in the pointcloud, + and is represented as a sequential array of items that define the dimensions + and their types, + + Returns: + List[PointcloudSchema] + """ + return self.get_schemas() + + @schemas.setter + def schemas(self, v): + self.set_schemas(v) + + def get_schemas(self, asset=None): + """Gets an Item or an Asset projection geometry. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + List[PointcloudSchema] + """ + if asset is None or 'pc:schemas' not in asset.properties: + schemas = self.item.properties.get('pc:schemas') + return [PointcloudSchema(s) for s in schemas] + else: + return [PointcloudSchema.create(s) for s in asset.properties.get('pc:schemas')] + + def set_schemas(self, schemas, asset=None): + """Set an Item or an Asset schema + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + dicts = [s.to_dict() for s in schemas] + if asset is None: + self.item.properties['pc:schemas'] = dicts + else: + asset.properties['pc:schemas'] = dicts + + @property + def density(self): + """Get or sets the density for the item. + + Density is defined as the number of points per square unit area. + + Returns: + int + """ + return self.get_density() + + @density.setter + def density(self, v): + self.set_density(v) + + def get_density(self, asset=None): + """Gets an Item or an Asset density. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + int + """ + if asset is None or 'pc:density' not in asset.properties: + return self.item.properties.get('pc:density') + else: + return asset.properties.get('pc:density') + + def set_density(self, density, asset=None): + """Set an Item or an Asset density property. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if asset is None: + self.item.properties['pc:density'] = density + else: + asset.properties['pc:density'] = density + + @property + def statistics(self): + """Get or sets the statistics for each property of the dataset. + + A sequential array of items mapping to pc:schemas defines per-channel statistics. + + Example:: + + item.ext.pointcloud.statistics = [{ 'name': 'red', 'min': 0, 'max': 255 }] + + Returns: + List[dict] + """ + return self.get_statistics() + + @statistics.setter + def statistics(self, v): + self.set_statistics(v) + + def get_statistics(self, asset=None): + """Gets an Item or an Asset centroid. + + If an Asset is supplied and the Item property exists on the Asset, + returns the Asset's value. Otherwise returns the Item's value + + Returns: + List[PointCloudStatistics] or None + """ + if asset is None or 'pc:statistics' not in asset.properties: + stats = self.item.properties.get('pc:statistics') + return [PointcloudStatistic(s) for s in stats] + else: + return [PointcloudStatistic.create(s) for s in asset.properties.get('pc:statistics')] + + def set_statistics(self, statistics, asset=None): + """Set an Item or an Asset centroid. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if statistics is not None: + statistics = [s.to_dict() for s in statistics] + if asset is None: + self.item.properties['pc:statistics'] = statistics + else: + asset.properties['pc:statistics'] = statistics + + @classmethod + def _object_links(cls): + return [] + + @classmethod + def from_item(cls, item): + return cls(item) + + +class PointcloudSchema: + """Defines a schema for dimension of a pointcloud (e.g., name, size, type) + + Use PointCloudSchema.create to create a new instance of PointCloudSchema from properties. + """ + def __init__(self, properties): + self.properties = properties + + def apply(self, name, size, type): + """Sets the properties for this PointCloudSchema. + + Args: + name (str): The name of dimension. + size (int): The size of the dimension in bytes. Whole bytes are supported. + type (str): Dimension type. Valid values are `floating`, `unsigned`, and `signed` + """ + self.properties['name'] = name + self.properties['size'] = size + self.properties['type'] = type + + @classmethod + def create(cls, *args): + """Creates a new PointCloudSchema. + + Args: + name (str): The name of dimension. + size (int): The size of the dimension in bytes. Whole bytes are supported. + type (str): Dimension type. Valid values are `floating`, `unsigned`, and `signed` + + Returns: + PointCloudSchema + """ + c = cls({}) + c.apply(*args) + return c + + @property + def size(self): + """Get or sets the size value. + + Returns: + int + """ + return self.properties.get('size') + + @size.setter + def size(self, v): + if not type(v) is int: + raise STACError("size must be an int! Invalid input: {}".format(v)) + + self.properties['size'] = v + + @property + def name(self): + """Get or sets the name property for this PointCloudSchema. + + Returns: + str + """ + return self.properties.get('name') + + @name.setter + def name(self, v): + if v is not None: + self.properties['name'] = v + else: + self.properties.pop('name', None) + + @property + def type(self): + """Get or sets the type property. Valid values are `floating`, `unsigned`, and `signed` + + Returns: + str + """ + return self.properties.get('type') + + @type.setter + def type(self, v): + if v is not None: + self.properties['type'] = v + else: + self.properties.pop('type', None) + + def __repr__(self): + return ''.format(self.name, self.size, self.type) + + def to_dict(self): + """Returns the dictionary representing the JSON of this PointCloudSchema. + + Returns: + dict: The wrapped dict of the PointCloudSchema that can be written out as JSON. + """ + return self.properties + + +class PointcloudStatistic: + """Defines a single statistic for Pointcloud channel or dimension + + Use PointcloudStatistic.create to create a new instance of LabelClasses from property values. + """ + def __init__(self, properties): + self.properties = properties + + def apply(self, + name, + position=None, + average=None, + count=None, + maximum=None, + minimum=None, + stddev=None, + variance=None): + """Sets the properties for this PointcloudStatistic. + + Args: + name (str): REQUIRED. The name of the channel. + position (int): Position of the channel in the schema. + average (float) The average of the channel. + count (int): The number of elements in the channel. + maximum (float): The maximum value of the channel. + minimum (float): The minimum value of the channel. + stddev (float): The standard deviation of the channel. + variance (float): The variance of the channel. + """ + self.properties['name'] = name + self.properties['position'] = position + self.properties['average'] = average + self.properties['count'] = count + self.properties['maximum'] = maximum + self.properties['minimum'] = minimum + self.properties['stddev'] = stddev + self.properties['variance'] = variance + + @classmethod + def create(cls, + name, + position=None, + average=None, + count=None, + maximum=None, + minimum=None, + stddev=None, + variance=None): + """Creates a new PointcloudStatistic class. + + Args: + name (str): REQUIRED. The name of the channel. + position (int): Position of the channel in the schema. + average (float) The average of the channel. + count (int): The number of elements in the channel. + maximum (float): The maximum value of the channel. + minimum (float): The minimum value of the channel. + stddev (float): The standard deviation of the channel. + variance (float): The variance of the channel. + + Returns: + LabelClasses + """ + c = cls({}) + c.apply(name, ) + return c + + @property + def name(self): + """Get or sets the name property + + Returns: + str + """ + return self.properties.get('name') + + @name.setter + def name(self, v): + if v is not None: + self.properties['name'] = v + else: + self.properties.pop('name', None) + + @property + def position(self): + """Get or sets the position property + + Returns: + int + """ + return self.properties.get('position') + + @position.setter + def position(self, v): + if v is not None: + self.properties['position'] = v + else: + self.properties.pop('position', None) + + @property + def average(self): + """Get or sets the average property + + Returns: + float + """ + return self.properties.get('average') + + @average.setter + def average(self, v): + if v is not None: + self.properties['average'] = v + else: + self.properties.pop('average', None) + + @property + def count(self): + """Get or sets the count property + + Returns: + int + """ + return self.properties.get('count') + + @count.setter + def count(self, v): + if v is not None: + self.properties['count'] = v + else: + self.properties.pop('count', None) + + @property + def maximum(self): + """Get or sets the maximum property + + Returns: + float + """ + return self.properties.get('maximum') + + @maximum.setter + def maximum(self, v): + if v is not None: + self.properties['maximum'] = v + else: + self.properties.pop('maximum', None) + + @property + def minimum(self): + """Get or sets the minimum property + + Returns: + float + """ + return self.properties.get('minimum') + + @minimum.setter + def minimum(self, v): + if v is not None: + self.properties['minimum'] = v + else: + self.properties.pop('minimum', None) + + @property + def stddev(self): + """Get or sets the stddev property + + Returns: + float + """ + return self.properties.get('stddev') + + @stddev.setter + def stddev(self, v): + if v is not None: + self.properties['stddev'] = v + else: + self.properties.pop('stddev', None) + + @property + def variance(self): + """Get or sets the variance property + + Returns: + float + """ + return self.properties.get('variance') + + @variance.setter + def variance(self, v): + if v is not None: + self.properties['variance'] = v + else: + self.properties.pop('variance', None) + + def __repr__(self): + return ''.format(str(self.properties)) + + def to_dict(self): + """Returns the dictionary representing the JSON of this PointcloudStatistic. + + Returns: + dict: The wrapped dict of the PointcloudStatistic that can be written out as JSON. + """ + return self.properties + + +POINTCLOUD_EXTENSION_DEFINITION = ExtensionDefinition(Extensions.POINTCLOUD, + [ExtendedObject(Item, PointcloudItemExt)]) diff --git a/pystac/validation/schema_uri_map.py b/pystac/validation/schema_uri_map.py index a2c8f45b3..27b5d0782 100644 --- a/pystac/validation/schema_uri_map.py +++ b/pystac/validation/schema_uri_map.py @@ -104,7 +104,7 @@ class DefaultSchemaUriMap(SchemaUriMap): })]), Extensions.POINTCLOUD: ( { - STACObjectType.ITEM: None # Schema is invalid JSON, fixed past 1.0.0-beta.2 + STACObjectType.ITEM: None # 'extensions/pointcloud/json-schema/schema.json' }, None), Extensions.PROJECTION: ({ diff --git a/tests/data-files/pointcloud/example-laz.json b/tests/data-files/pointcloud/example-laz.json new file mode 100644 index 000000000..a9bfbf87e --- /dev/null +++ b/tests/data-files/pointcloud/example-laz.json @@ -0,0 +1,302 @@ +{ + "stac_version": "1.0.0-beta.2", + "stac_extensions": [ + "pointcloud" + ], + "assets": {}, + "bbox": [ + -123.0755422, + 44.04971882, + 123.791472, + -123.0619599, + 44.06278031, + 187.531248 + ], + "geometry": { + "coordinates": [ + [ + [ + -123.07498674, + 44.04971882 + ], + [ + -123.07554223, + 44.06248623 + ], + [ + -123.0625126, + 44.06278031 + ], + [ + -123.06195992, + 44.05001283 + ], + [ + -123.07498674, + 44.04971882 + ] + ] + ], + "type": "Polygon" + }, + "id": "autzen-full.laz", + "links": [ + { + "href": "/Users/hobu/dev/git/pdal/test/data/autzen/autzen-full.laz", + "rel": "self" + } + ], + "properties": { + "datetime": "2013-07-17T00:00:00Z", + "pc:count": 10653336, + "pc:density": 0, + "pc:encoding": "LASzip", + "pc:schemas": [ + { + "name": "X", + "size": 8, + "type": "floating" + }, + { + "name": "Y", + "size": 8, + "type": "floating" + }, + { + "name": "Z", + "size": 8, + "type": "floating" + }, + { + "name": "Intensity", + "size": 2, + "type": "unsigned" + }, + { + "name": "ReturnNumber", + "size": 1, + "type": "unsigned" + }, + { + "name": "NumberOfReturns", + "size": 1, + "type": "unsigned" + }, + { + "name": "ScanDirectionFlag", + "size": 1, + "type": "unsigned" + }, + { + "name": "EdgeOfFlightLine", + "size": 1, + "type": "unsigned" + }, + { + "name": "Classification", + "size": 1, + "type": "unsigned" + }, + { + "name": "ScanAngleRank", + "size": 4, + "type": "floating" + }, + { + "name": "UserData", + "size": 1, + "type": "unsigned" + }, + { + "name": "PointSourceId", + "size": 2, + "type": "unsigned" + }, + { + "name": "GpsTime", + "size": 8, + "type": "floating" + }, + { + "name": "Red", + "size": 2, + "type": "unsigned" + }, + { + "name": "Green", + "size": 2, + "type": "unsigned" + }, + { + "name": "Blue", + "size": 2, + "type": "unsigned" + } + ], + "pc:statistics": [ + { + "average": 637294.1783, + "count": 10653336, + "maximum": 639003.73, + "minimum": 635577.79, + "name": "X", + "position": 0, + "stddev": 967.9329805, + "variance": 936894.2548 + }, + { + "average": 851247.6953, + "count": 10653336, + "maximum": 853537.66, + "minimum": 848882.15, + "name": "Y", + "position": 1, + "stddev": 1322.356387, + "variance": 1748626.415 + }, + { + "average": 434.1025002, + "count": 10653336, + "maximum": 615.26, + "minimum": 406.14, + "name": "Z", + "position": 2, + "stddev": 24.67893148, + "variance": 609.0496589 + }, + { + "average": 77.14742312, + "count": 10653336, + "maximum": 254, + "minimum": 0, + "name": "Intensity", + "position": 3, + "stddev": 62.62422344, + "variance": 3921.793362 + }, + { + "average": 1.17801438, + "count": 10653336, + "maximum": 4, + "minimum": 1, + "name": "ReturnNumber", + "position": 4, + "stddev": 0.4653418642, + "variance": 0.2165430505 + }, + { + "average": 1.358579791, + "count": 10653336, + "maximum": 4, + "minimum": 1, + "name": "NumberOfReturns", + "position": 5, + "stddev": 0.6656066447, + "variance": 0.4430322055 + }, + { + "average": 0.4989654884, + "count": 10653336, + "maximum": 1, + "minimum": 0, + "name": "ScanDirectionFlag", + "position": 6, + "stddev": 0.4999993213, + "variance": 0.2499993213 + }, + { + "average": 0, + "count": 10653336, + "maximum": 0, + "minimum": 0, + "name": "EdgeOfFlightLine", + "position": 7, + "stddev": 0, + "variance": 0 + }, + { + "average": 1.256686262, + "count": 10653336, + "maximum": 2, + "minimum": 1, + "name": "Classification", + "position": 8, + "stddev": 0.436805292, + "variance": 0.1907988632 + }, + { + "average": -0.812061405, + "count": 10653336, + "maximum": 20, + "minimum": -21, + "name": "ScanAngleRank", + "position": 9, + "stddev": 8.484319324, + "variance": 71.98367439 + }, + { + "average": 126.4052859, + "count": 10653336, + "maximum": 156, + "minimum": 115, + "name": "UserData", + "position": 10, + "stddev": 3.833000243, + "variance": 14.69189086 + }, + { + "average": 7329.903705, + "count": 10653336, + "maximum": 7334, + "minimum": 7326, + "name": "PointSourceId", + "position": 11, + "stddev": 3.107430355, + "variance": 9.656123408 + }, + { + "average": 121.3214254, + "count": 10653336, + "maximum": 255, + "minimum": 35, + "name": "Red", + "position": 12, + "stddev": 45.56263834, + "variance": 2075.954013 + }, + { + "average": 126.2526972, + "count": 10653336, + "maximum": 255, + "minimum": 49, + "name": "Green", + "position": 13, + "stddev": 36.85451838, + "variance": 1358.255525 + }, + { + "average": 111.2207554, + "count": 10653336, + "maximum": 255, + "minimum": 49, + "name": "Blue", + "position": 14, + "stddev": 31.95561927, + "variance": 1021.161603 + }, + { + "average": 247608.4011, + "count": 10653336, + "maximum": 249783.703, + "minimum": 245369.8966, + "name": "GpsTime", + "position": 15, + "stddev": 1178.538827, + "variance": 1388953.768 + } + ], + "pc:type": "lidar", + "title": "USGS 3DEP LiDAR" + }, + "type": "Feature" +} diff --git a/tests/extensions/test_pointcloud.py b/tests/extensions/test_pointcloud.py new file mode 100644 index 000000000..ac90a3ba5 --- /dev/null +++ b/tests/extensions/test_pointcloud.py @@ -0,0 +1,186 @@ +import json +import unittest +# from copy import deepcopy + +import pystac +from pystac import (Item, Extensions) +from pystac.extensions import ExtensionError +from pystac.extensions.pointcloud import PointcloudSchema, PointcloudStatistic +from tests.utils import (TestCases, test_to_from_dict) + + +class PointcloudTest(unittest.TestCase): + def setUp(self): + self.maxDiff = None + self.example_uri = TestCases.get_path('data-files/pointcloud/example-laz.json') + + def test_to_from_dict(self): + with open(self.example_uri) as f: + d = json.load(f) + test_to_from_dict(self, Item, d) + + def test_apply(self): + item = next(TestCases.test_case_2().get_all_items()) + with self.assertRaises(ExtensionError): + item.ext.pointcloud + + item.ext.enable(Extensions.POINTCLOUD) + item.ext.pointcloud.apply(1000, 'lidar', 'laszip', + [PointcloudSchema({ + 'name': 'X', + 'size': 8, + 'type': 'floating' + })]) + + def test_validate_pointcloud(self): + item = pystac.read_file(self.example_uri) + item.validate() + + def test_count(self): + pc_item = pystac.read_file(self.example_uri) + + # Get + self.assertIn("pc:count", pc_item.properties) + pc_count = pc_item.ext.pointcloud.count + self.assertEqual(pc_count, pc_item.properties['pc:count']) + + # Set + pc_item.ext.pointcloud.count = pc_count + 100 + self.assertEqual(pc_count + 100, pc_item.properties['pc:count']) + + # Validate + pc_item.validate + + # Cannot test validation errors until the pointcloud schema.json syntax is fixed + # Ensure setting bad count fails validation + + # with self.assertRaises(STACValidationError): + # pc_item.ext.pointcloud.count = 'not_an_int' + # pc_item.validate() + + def test_type(self): + pc_item = pystac.read_file(self.example_uri) + + # Get + self.assertIn("pc:type", pc_item.properties) + pc_type = pc_item.ext.pointcloud.type + self.assertEqual(pc_type, pc_item.properties['pc:type']) + + # Set + pc_item.ext.pointcloud.type = 'sonar' + self.assertEqual('sonar', pc_item.properties['pc:type']) + + # Validate + pc_item.validate + + def test_encoding(self): + pc_item = pystac.read_file(self.example_uri) + + # Get + self.assertIn("pc:encoding", pc_item.properties) + pc_encoding = pc_item.ext.pointcloud.encoding + self.assertEqual(pc_encoding, pc_item.properties['pc:encoding']) + + # Set + pc_item.ext.pointcloud.encoding = 'binary' + self.assertEqual('binary', pc_item.properties['pc:encoding']) + + # Validate + pc_item.validate + + def test_schemas(self): + pc_item = pystac.read_file(self.example_uri) + + # Get + self.assertIn("pc:schemas", pc_item.properties) + pc_schemas = [s.to_dict() for s in pc_item.ext.pointcloud.schemas] + self.assertEqual(pc_schemas, pc_item.properties['pc:schemas']) + + # Set + schema = [PointcloudSchema({'name': 'X', 'size': 8, 'type': 'floating'})] + pc_item.ext.pointcloud.schemas = schema + self.assertEqual([s.to_dict() for s in schema], pc_item.properties['pc:schemas']) + + # Validate + pc_item.validate + + def test_statistics(self): + pc_item = pystac.read_file(self.example_uri) + + # Get + self.assertIn("pc:statistics", pc_item.properties) + pc_statistics = [s.to_dict() for s in pc_item.ext.pointcloud.statistics] + self.assertEqual(pc_statistics, pc_item.properties['pc:statistics']) + + # Set + stats = [ + PointcloudStatistic({ + "average": 1, + "count": 1, + "maximum": 1, + "minimum": 1, + "name": "Test", + "position": 1, + "stddev": 1, + "variance": 1 + }) + ] + pc_item.ext.pointcloud.statistics = stats + self.assertEqual([s.to_dict() for s in stats], pc_item.properties['pc:statistics']) + + # Validate + pc_item.validate + + def test_density(self): + pc_item = pystac.read_file(self.example_uri) + # Get + self.assertIn("pc:density", pc_item.properties) + pc_density = pc_item.ext.pointcloud.density + self.assertEqual(pc_density, pc_item.properties['pc:density']) + # Set + density = 100 + pc_item.ext.pointcloud.density = density + self.assertEqual(density, pc_item.properties['pc:density']) + # Validate + pc_item.validate + + def test_pointcloud_schema(self): + props = { + "name": "test", + "size": 8, + "type": "floating", + } + schema = PointcloudSchema(props) + self.assertEqual(props, schema.properties) + + # test all getters and setters + for k in props: + if isinstance(props[k], str): + val = props[k] + str(1) + else: + val = props[k] + 1 + setattr(schema, k, val) + self.assertEqual(getattr(schema, k), val) + + def test_pointcloud_statistics(self): + props = { + "average": 1, + "count": 1, + "maximum": 1, + "minimum": 1, + "name": "Test", + "position": 1, + "stddev": 1, + "variance": 1 + } + stat = PointcloudStatistic(props) + self.assertEqual(props, stat.properties) + + # test all getters and setters + for k in props: + if isinstance(props[k], str): + val = props[k] + str(1) + else: + val = props[k] + 1 + setattr(stat, k, val) + self.assertEqual(getattr(stat, k), val)