From 434d260927d39f10a3991e2fea8a1aac40e5ed9f Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Tue, 18 Aug 2020 16:02:54 -0600 Subject: [PATCH 01/12] first version of the pointcloud extension class --- pystac/extensions/pointcloud.py | 316 ++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 pystac/extensions/pointcloud.py diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py new file mode 100644 index 000000000..908d70524 --- /dev/null +++ b/pystac/extensions/pointcloud.py @@ -0,0 +1,316 @@ +from pystac import Extensions +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): + """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. + """ + self.count = count + self.type = type + self.encoding = encoding + self.schemas = schemas + self.density = density + self.statistics = statistics + + @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 epsg. + + 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['proj:count'] = count + else: + asset.properties['proj:count'] = count + + @property + def type(self): + """Get or sets the WKT2 string representing the Coordinate Reference System (CRS) + that the proj:geometry and proj:bbox fields represent + + This value is a `WKT2 string `_. + If the data does not have a CRS, such as in the case of non-rectified imagery with Ground + Control Points, wkt2 should be set to null. It should also be set to null if a CRS exists, + but for which a WKT2 string does not exist. + + 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 'proj:type' not in asset.properties: + return self.item.properties.get('proj:type') + else: + return asset.properties.get('proj: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['proj:type'] = type + else: + asset.properties['proj:type'] = type + + @property + def encoding(self): + """Get or sets the encoding + + Returns: + dict + """ + return self.get_projjson() + + @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: + dict + """ + if asset is None or 'proj:encoding' not in asset.properties: + return self.item.properties.get('proj:encoding') + else: + return asset.properties.get('proj: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['proj:encoding'] = encoding + else: + asset.properties['proj:encoding'] = encoding + + @property + def schema(self): + """Get or sets a Polygon GeoJSON dict representing the footprint of this item. + + This dict should be formatted according the Polygon object format specified in + `RFC 7946, sections 3.1.6 `_, + except not necessarily in EPSG:4326 as required by RFC7946. Specified based on the + ``epsg``, ``projjson`` or ``wkt2`` fields (not necessarily EPSG:4326). + Ideally, this will be represented by a Polygon with five coordinates, as the item in + the asset data CRS should be a square aligned to the original CRS grid. + + Returns: + dict + """ + 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: + dict + """ + if asset is None or 'proj:schema' not in asset.properties: + return self.item.properties.get('proj:schema') + else: + return asset.properties.get('proj:schemas') + + def set_schemas(self, schemas, asset=None): + """Set an Item or an Asset projection geometry. + + If an Asset is supplied, sets the property on the Asset. + Otherwise sets the Item's value. + """ + if asset is None: + self.item.properties['proj:schemas'] = schemas + else: + asset.properties['proj:schemas'] = schemas + + @property + def density(self): + """Get or sets the bounding box of the assets represented by this item in the asset + data CRS. + + Specified as 4 or 6 coordinates based on the CRS defined in the ``epsg``, ``projjson`` + or ``wkt2`` properties. First two numbers are coordinates of the lower left corner, + followed by coordinates of upper right corner, e.g., + [west, south, east, north], [xmin, ymin, xmax, ymax], [left, down, right, up], + or [west, south, lowest, east, north, highest]. The length of the array must be 2*n + where n is the number of dimensions. + + Returns: + List[float] + """ + 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 projection bbox. + + 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[float] + """ + if asset is None or 'proj:density' not in asset.properties: + return self.item.properties.get('proj:density') + else: + return asset.properties.get('proj: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['proj:density'] = density + else: + asset.properties['proj:density'] = density + + @property + def statistics(self): + """Get or sets coordinates representing the centroid of the item in the asset data CRS. + + Exmample:: + + item.ext.proj.centroid = { 'lat': 0.0, 'lon': 0.0 } + + Returns: + dict + """ + return self.get_statistics() + + @centroid.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: + dict + """ + if asset is None or 'proj:statistics' not in asset.properties: + return self.item.properties.get('proj:statistics') + else: + return asset.properties.get('proj: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 asset is None: + self.item.properties['proj:statistics'] = statistics + else: + asset.properties['proj:statistics'] = statistics + + + @classmethod + def _object_links(cls): + return [] + + @classmethod + def from_item(cls, item): + return cls(item) + + +POINTCLOUD_EXTENSION_DEFINITION = ExtensionDefinition(Extensions.POINTCLOUD, + [ExtendedObject(Item, PointcloudItemExt)]) From aa15a0cfb340bfd9cbdaf0fe4092e4723688cfd3 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Tue, 18 Aug 2020 16:04:16 -0600 Subject: [PATCH 02/12] fix a few small typos --- pystac/extensions/pointcloud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py index 908d70524..09e644bd1 100644 --- a/pystac/extensions/pointcloud.py +++ b/pystac/extensions/pointcloud.py @@ -170,7 +170,7 @@ def set_encoding(self, encoding, asset=None): asset.properties['proj:encoding'] = encoding @property - def schema(self): + def schemas(self): """Get or sets a Polygon GeoJSON dict representing the footprint of this item. This dict should be formatted according the Polygon object format specified in @@ -273,7 +273,7 @@ def statistics(self): """ return self.get_statistics() - @centroid.setter + @statistics.setter def statistics(self, v): self.set_statistics(v) From 580fd884f4e93ab865ee0147128523b782c26141 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Thu, 20 Aug 2020 13:14:31 -0600 Subject: [PATCH 03/12] enable pointcloud as an extension in __init__.py --- pystac/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pystac/__init__.py b/pystac/__init__.py index 3dacc58c4..4de6f0849 100644 --- a/pystac/__init__.py +++ b/pystac/__init__.py @@ -33,11 +33,13 @@ 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 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 ]) From fc7d3ffbb99e40f8611b5957198edf8e7e5fe44f Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 21 Aug 2020 08:56:00 -0600 Subject: [PATCH 04/12] finishing the pc ext code comments and adding the class member to docs.rst --- docs/api.rst | 14 +++ pystac/extensions/pointcloud.py | 164 ++++++++++++++++++++------------ 2 files changed, 115 insertions(+), 63 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 1ff1caa4f..130143141 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/extensions/pointcloud.py b/pystac/extensions/pointcloud.py index 09e644bd1..a8d7cdb4a 100644 --- a/pystac/extensions/pointcloud.py +++ b/pystac/extensions/pointcloud.py @@ -28,7 +28,8 @@ def apply(self, encoding, schemas, density=None, - statistics=None): + statistics=None, + epsg=None): """Applies Pointcloud extension properties to the extended Item. Args: @@ -41,6 +42,7 @@ def apply(self, 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 @@ -48,6 +50,7 @@ def apply(self, self.schemas = schemas self.density = density self.statistics = statistics + self.epsg = epsg @property def count(self): @@ -63,7 +66,7 @@ def count(self, v): self.set_count(v) def get_count(self, asset=None): - """Gets an Item or an Asset epsg. + """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 @@ -83,19 +86,13 @@ def set_count(self, count, asset=None): Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:count'] = count + self.item.properties['pc:count'] = count else: - asset.properties['proj:count'] = count + asset.properties['pc:count'] = count @property def type(self): - """Get or sets the WKT2 string representing the Coordinate Reference System (CRS) - that the proj:geometry and proj:bbox fields represent - - This value is a `WKT2 string `_. - If the data does not have a CRS, such as in the case of non-rectified imagery with Ground - Control Points, wkt2 should be set to null. It should also be set to null if a CRS exists, - but for which a WKT2 string does not exist. + """Get or sets the pc:type prop on the Item Returns: str @@ -115,10 +112,10 @@ def get_type(self, asset=None): Returns: str """ - if asset is None or 'proj:type' not in asset.properties: - return self.item.properties.get('proj:type') + if asset is None or 'pc:type' not in asset.properties: + return self.item.properties.get('pc:type') else: - return asset.properties.get('proj:type') + return asset.properties.get('pc:type') def set_type(self, type, asset=None): """Set an Item or an Asset type. @@ -127,18 +124,21 @@ def set_type(self, type, asset=None): Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:type'] = type + self.item.properties['pc:type'] = type else: - asset.properties['proj:type'] = type + asset.properties['pc:type'] = type @property def encoding(self): - """Get or sets the encoding + """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: - dict + str """ - return self.get_projjson() + return self.get_encoding() @encoding.setter def encoding(self, v): @@ -151,12 +151,12 @@ def get_encoding(self, asset=None): returns the Asset's value. Otherwise returns the Item's value Returns: - dict + str """ - if asset is None or 'proj:encoding' not in asset.properties: - return self.item.properties.get('proj:encoding') + if asset is None or 'pc:encoding' not in asset.properties: + return self.item.properties.get('pc:encoding') else: - return asset.properties.get('proj:encoding') + return asset.properties.get('pc:encoding') def set_encoding(self, encoding, asset=None): """Set an Item or an Asset encoding. @@ -165,23 +165,19 @@ def set_encoding(self, encoding, asset=None): Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:encoding'] = encoding + self.item.properties['pc:encoding'] = encoding else: - asset.properties['proj:encoding'] = encoding + asset.properties['pc:encoding'] = encoding @property def schemas(self): - """Get or sets a Polygon GeoJSON dict representing the footprint of this item. + """Get or sets a - This dict should be formatted according the Polygon object format specified in - `RFC 7946, sections 3.1.6 `_, - except not necessarily in EPSG:4326 as required by RFC7946. Specified based on the - ``epsg``, ``projjson`` or ``wkt2`` fields (not necessarily EPSG:4326). - Ideally, this will be represented by a Polygon with five coordinates, as the item in - the asset data CRS should be a square aligned to the original CRS grid. + 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: - dict + List[dict] """ return self.get_schemas() @@ -198,36 +194,30 @@ def get_schemas(self, asset=None): Returns: dict """ - if asset is None or 'proj:schema' not in asset.properties: - return self.item.properties.get('proj:schema') + if asset is None or 'pc:schema' not in asset.properties: + return self.item.properties.get('pc:schema') else: - return asset.properties.get('proj:schemas') + return asset.properties.get('pc:schemas') def set_schemas(self, schemas, asset=None): - """Set an Item or an Asset projection geometry. + """Set an Item or an Asset schema If an Asset is supplied, sets the property on the Asset. Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:schemas'] = schemas + self.item.properties['pc:schemas'] = schemas else: - asset.properties['proj:schemas'] = schemas + asset.properties['pc:schemas'] = schemas @property def density(self): - """Get or sets the bounding box of the assets represented by this item in the asset - data CRS. + """Get or sets the density for the item. - Specified as 4 or 6 coordinates based on the CRS defined in the ``epsg``, ``projjson`` - or ``wkt2`` properties. First two numbers are coordinates of the lower left corner, - followed by coordinates of upper right corner, e.g., - [west, south, east, north], [xmin, ymin, xmax, ymax], [left, down, right, up], - or [west, south, lowest, east, north, highest]. The length of the array must be 2*n - where n is the number of dimensions. + Density is defined as the number of points per square unit area. Returns: - List[float] + int """ return self.get_density() @@ -236,18 +226,18 @@ def density(self, v): self.set_density(v) def get_density(self, asset=None): - """Gets an Item or an Asset projection bbox. + """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: - List[float] + int """ - if asset is None or 'proj:density' not in asset.properties: - return self.item.properties.get('proj:density') + if asset is None or 'pc:density' not in asset.properties: + return self.item.properties.get('pc:density') else: - return asset.properties.get('proj:density') + return asset.properties.get('pc:density') def set_density(self, density, asset=None): """Set an Item or an Asset density property. @@ -256,20 +246,22 @@ def set_density(self, density, asset=None): Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:density'] = density + self.item.properties['pc:density'] = density else: - asset.properties['proj:density'] = density + asset.properties['pc:density'] = density @property def statistics(self): - """Get or sets coordinates representing the centroid of the item in the asset data CRS. + """Get or sets the statistics for each property of the dataset. + + A sequential array of items mapping to pc:schemas defines per-channel statistics. Exmample:: - item.ext.proj.centroid = { 'lat': 0.0, 'lon': 0.0 } + item.ext.pointcloud.statistics = [{ 'name': 'red', 'min': 0, 'max': 255 }] Returns: - dict + List[dict] """ return self.get_statistics() @@ -286,10 +278,10 @@ def get_statistics(self, asset=None): Returns: dict """ - if asset is None or 'proj:statistics' not in asset.properties: - return self.item.properties.get('proj:statistics') + if asset is None or 'pc:statistics' not in asset.properties: + return self.item.properties.get('pc:statistics') else: - return asset.properties.get('proj:statistics') + return asset.properties.get('pc:statistics') def set_statistics(self, statistics, asset=None): """Set an Item or an Asset centroid. @@ -298,9 +290,55 @@ def set_statistics(self, statistics, asset=None): Otherwise sets the Item's value. """ if asset is None: - self.item.properties['proj:statistics'] = statistics + self.item.properties['pc:statistics'] = statistics + else: + asset.properties['pc:statistics'] = statistics + + @property + def epsg(self): + """Get or sets the EPSG code of the datasource. + + A Coordinate Reference System (CRS) is the data reference system (sometimes called a + 'projection') used by the asset data, and can usually be referenced using an + `EPSG code `_. + If the asset data does not have a CRS, such as in the case of non-rectified imagery with + Ground Control Points, epsg should be set to None. + It should also be set to null if a CRS exists, but for which there is no valid EPSG code. + + Returns: + str + """ + return self.get_epsg() + + @epsg.setter + def epsg(self, v): + self.set_epsg(v) + + def get_epsg(self, asset=None): + """Gets an Item or an Asset epsg. + + 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:epsg' not in asset.properties: + return self.item.properties.get('pc:epsg') + else: + return asset.properties.get('pc:epsg') + + def set_epsg(self, epsg, asset=None): + """Set an Item or an Asset epsg. + + 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:epsg'] = epsg else: - asset.properties['proj:statistics'] = statistics + asset.properties['pc:epsg'] = epsg + @classmethod From 71447ff0e72724df309a969ebf430c51fd535733 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 21 Aug 2020 13:11:01 -0600 Subject: [PATCH 05/12] tests are all passing --- tests/data-files/pointcloud/example-laz.json | 302 +++++++++++++++++++ tests/extensions/test_pointcloud.py | 102 +++++++ 2 files changed, 404 insertions(+) create mode 100644 tests/data-files/pointcloud/example-laz.json create mode 100644 tests/extensions/test_pointcloud.py 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..e5d2d11ff --- /dev/null +++ b/tests/extensions/test_pointcloud.py @@ -0,0 +1,102 @@ +import json +import unittest +# from copy import deepcopy + +import pystac +from pystac import (Item, Extensions) +from pystac.extensions import ExtensionError +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', + {'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 text 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 = pc_item.ext.pointcloud.schemas + self.assertEqual(pc_schemas, pc_item.properties['pc:schemas']) + + # Set + schema = [{'name': 'X', 'size': 8, 'type': 'floating'}] + pc_item.ext.pointcloud.schemas = schema + self.assertEqual(schema, pc_item.properties['pc:schemas']) + + # Validate + pc_item.validate From 4919d0b9a918b1fe5dbcb737d3ab80e10e6fe041 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 21 Aug 2020 13:43:08 -0600 Subject: [PATCH 06/12] was missing the flake changes in the PR commit --- pystac/extensions/pointcloud.py | 27 ++++++++++----------------- pystac/validation/schema_uri_map.py | 3 ++- tests/extensions/test_pointcloud.py | 10 +++++----- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py index a8d7cdb4a..cd445c54a 100644 --- a/pystac/extensions/pointcloud.py +++ b/pystac/extensions/pointcloud.py @@ -22,14 +22,7 @@ def __init__(self, item): self.item = item - def apply(self, - count, - type, - encoding, - schemas, - density=None, - statistics=None, - epsg=None): + def apply(self, count, type, encoding, schemas, density=None, statistics=None, epsg=None): """Applies Pointcloud extension properties to the extended Item. Args: @@ -41,7 +34,8 @@ def apply(self, 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. + 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 @@ -132,7 +126,7 @@ def set_type(self, type, asset=None): def encoding(self): """Get or sets the content-encoding for the item. - The content-encoding is the underlying encoding format for the point cloud. + The content-encoding is the underlying encoding format for the point cloud. Examples may include: laszip, ascii, binary, etc. Returns: @@ -171,10 +165,11 @@ def set_encoding(self, encoding, asset=None): @property def schemas(self): - """Get or sets a + """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. + 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[dict] @@ -195,7 +190,7 @@ def get_schemas(self, asset=None): dict """ if asset is None or 'pc:schema' not in asset.properties: - return self.item.properties.get('pc:schema') + return self.item.properties.get('pc:schemas') else: return asset.properties.get('pc:schemas') @@ -212,7 +207,7 @@ def set_schemas(self, schemas, asset=None): @property def density(self): - """Get or sets the density for the item. + """Get or sets the density for the item. Density is defined as the number of points per square unit area. @@ -339,8 +334,6 @@ def set_epsg(self, epsg, asset=None): else: asset.properties['pc:epsg'] = epsg - - @classmethod def _object_links(cls): return [] diff --git a/pystac/validation/schema_uri_map.py b/pystac/validation/schema_uri_map.py index 183f19b22..88ca530a8 100644 --- a/pystac/validation/schema_uri_map.py +++ b/pystac/validation/schema_uri_map.py @@ -103,8 +103,9 @@ class DefaultSchemaUriMap(SchemaUriMap): STACObjectType.ITEM: 'extensions/label/schema.json' })]), Extensions.POINTCLOUD: ( + # Schema is invalid JSON, fixed past 1.0.0-beta.2 { - 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/extensions/test_pointcloud.py b/tests/extensions/test_pointcloud.py index e5d2d11ff..0fabab064 100644 --- a/tests/extensions/test_pointcloud.py +++ b/tests/extensions/test_pointcloud.py @@ -24,11 +24,11 @@ def test_apply(self): item.ext.pointcloud item.ext.enable(Extensions.POINTCLOUD) - item.ext.pointcloud.apply( - 1000, - 'lidar', - 'laszip', - {'name': 'X', 'size': 8, 'type': 'floating'}) + item.ext.pointcloud.apply(1000, 'lidar', 'laszip', { + 'name': 'X', + 'size': 8, + 'type': 'floating' + }) def test_validate_pointcloud(self): item = pystac.read_file(self.example_uri) From 59599327a6a5f9db8aa859d575f203ee959f992d Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Thu, 3 Sep 2020 13:45:59 -0600 Subject: [PATCH 07/12] pointing the schema uri map to the live json schema for pointclouds --- pystac/validation/schema_uri_map.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pystac/validation/schema_uri_map.py b/pystac/validation/schema_uri_map.py index 88ca530a8..f2713f867 100644 --- a/pystac/validation/schema_uri_map.py +++ b/pystac/validation/schema_uri_map.py @@ -102,12 +102,10 @@ class DefaultSchemaUriMap(SchemaUriMap): }, [(STACVersionRange(min_version='0.8.1-rc1', max_version='0.8.1'), { STACObjectType.ITEM: 'extensions/label/schema.json' })]), - Extensions.POINTCLOUD: ( - # Schema is invalid JSON, fixed past 1.0.0-beta.2 - { - STACObjectType.ITEM: None # 'extensions/pointcloud/json-schema/schema.json' - }, - None), + Extensions.POINTCLOUD: ({ + STACObjectType.ITEM: + 'extensions/pointcloud/json-schema/schema.json' + }, None), Extensions.PROJECTION: ({ STACObjectType.ITEM: 'extensions/projection/json-schema/schema.json' From a5846ff200628128a4339d5adccc282cdb4e1606 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Thu, 3 Sep 2020 13:50:54 -0600 Subject: [PATCH 08/12] remove epsg as from pointcloud extension. STAC supports using the projection extension to define a proj --- pystac/extensions/pointcloud.py | 45 --------------------------------- 1 file changed, 45 deletions(-) diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py index cd445c54a..799af2a5f 100644 --- a/pystac/extensions/pointcloud.py +++ b/pystac/extensions/pointcloud.py @@ -289,51 +289,6 @@ def set_statistics(self, statistics, asset=None): else: asset.properties['pc:statistics'] = statistics - @property - def epsg(self): - """Get or sets the EPSG code of the datasource. - - A Coordinate Reference System (CRS) is the data reference system (sometimes called a - 'projection') used by the asset data, and can usually be referenced using an - `EPSG code `_. - If the asset data does not have a CRS, such as in the case of non-rectified imagery with - Ground Control Points, epsg should be set to None. - It should also be set to null if a CRS exists, but for which there is no valid EPSG code. - - Returns: - str - """ - return self.get_epsg() - - @epsg.setter - def epsg(self, v): - self.set_epsg(v) - - def get_epsg(self, asset=None): - """Gets an Item or an Asset epsg. - - 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:epsg' not in asset.properties: - return self.item.properties.get('pc:epsg') - else: - return asset.properties.get('pc:epsg') - - def set_epsg(self, epsg, asset=None): - """Set an Item or an Asset epsg. - - 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:epsg'] = epsg - else: - asset.properties['pc:epsg'] = epsg - @classmethod def _object_links(cls): return [] From c20aa66032daf4aedbea654e2e02cdc3fb9d14c3 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 4 Sep 2020 13:11:20 -0600 Subject: [PATCH 09/12] revert changes to schema_uri_map - waiting on stac-spec to get updated with the non breaking json --- pystac/validation/schema_uri_map.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pystac/validation/schema_uri_map.py b/pystac/validation/schema_uri_map.py index f2713f867..9bae8fb11 100644 --- a/pystac/validation/schema_uri_map.py +++ b/pystac/validation/schema_uri_map.py @@ -102,10 +102,11 @@ class DefaultSchemaUriMap(SchemaUriMap): }, [(STACVersionRange(min_version='0.8.1-rc1', max_version='0.8.1'), { STACObjectType.ITEM: 'extensions/label/schema.json' })]), - Extensions.POINTCLOUD: ({ - STACObjectType.ITEM: - 'extensions/pointcloud/json-schema/schema.json' - }, None), + Extensions.POINTCLOUD: ( + { + STACObjectType.ITEM: None # 'extensions/pointcloud/json-schema/schema.json' + }, + None), Extensions.PROJECTION: ({ STACObjectType.ITEM: 'extensions/projection/json-schema/schema.json' From 27ef62d60f3ea2dc12714280254fef709b83aa89 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 4 Sep 2020 13:12:48 -0600 Subject: [PATCH 10/12] adds PointcloudSchema as a class and PointcloudStatistic as a class. adds tests for each. --- pystac/extensions/pointcloud.py | 332 +++++++++++++++++++++++++++- tests/extensions/test_pointcloud.py | 93 +++++++- 2 files changed, 407 insertions(+), 18 deletions(-) diff --git a/pystac/extensions/pointcloud.py b/pystac/extensions/pointcloud.py index 799af2a5f..e0c467b51 100644 --- a/pystac/extensions/pointcloud.py +++ b/pystac/extensions/pointcloud.py @@ -1,4 +1,4 @@ -from pystac import Extensions +from pystac import Extensions, STACError from pystac.item import Item from pystac.extensions.base import (ItemExtension, ExtensionDefinition, ExtendedObject) @@ -172,7 +172,7 @@ def schemas(self): and their types, Returns: - List[dict] + List[PointcloudSchema] """ return self.get_schemas() @@ -187,12 +187,13 @@ def get_schemas(self, asset=None): returns the Asset's value. Otherwise returns the Item's value Returns: - dict + List[PointcloudSchema] """ - if asset is None or 'pc:schema' not in asset.properties: - return self.item.properties.get('pc:schemas') + 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 asset.properties.get('pc:schemas') + 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 @@ -200,10 +201,11 @@ def set_schemas(self, schemas, asset=None): 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'] = schemas + self.item.properties['pc:schemas'] = dicts else: - asset.properties['pc:schemas'] = schemas + asset.properties['pc:schemas'] = dicts @property def density(self): @@ -251,7 +253,7 @@ def statistics(self): A sequential array of items mapping to pc:schemas defines per-channel statistics. - Exmample:: + Example:: item.ext.pointcloud.statistics = [{ 'name': 'red', 'min': 0, 'max': 255 }] @@ -271,12 +273,13 @@ def get_statistics(self, asset=None): returns the Asset's value. Otherwise returns the Item's value Returns: - dict + List[PointCloudStatistics] or None """ if asset is None or 'pc:statistics' not in asset.properties: - return self.item.properties.get('pc:statistics') + stats = self.item.properties.get('pc:statistics') + return [PointcloudStatistic(s) for s in stats] else: - return asset.properties.get('pc:statistics') + 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. @@ -284,6 +287,8 @@ def set_statistics(self, statistics, asset=None): 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: @@ -298,5 +303,308 @@ 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/tests/extensions/test_pointcloud.py b/tests/extensions/test_pointcloud.py index 0fabab064..e8fae73b4 100644 --- a/tests/extensions/test_pointcloud.py +++ b/tests/extensions/test_pointcloud.py @@ -5,6 +5,7 @@ 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) @@ -24,11 +25,11 @@ def test_apply(self): item.ext.pointcloud item.ext.enable(Extensions.POINTCLOUD) - item.ext.pointcloud.apply(1000, 'lidar', 'laszip', { + 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) @@ -49,8 +50,9 @@ def test_count(self): # Validate pc_item.validate - # Cannot text validation errors until the pointcloud schema.json syntax is fixed + # 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() @@ -90,13 +92,92 @@ def test_schemas(self): # Get self.assertIn("pc:schemas", pc_item.properties) - pc_schemas = pc_item.ext.pointcloud.schemas + pc_schemas = [s.to_dict() for s in pc_item.ext.pointcloud.schemas] self.assertEqual(pc_schemas, pc_item.properties['pc:schemas']) # Set - schema = [{'name': 'X', 'size': 8, 'type': 'floating'}] + schema = [PointcloudSchema({'name': 'X', 'size': 8, 'type': 'floating'})] pc_item.ext.pointcloud.schemas = schema - self.assertEqual(schema, pc_item.properties['pc:schemas']) + 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) From ae902a6cbbb6d5a4319bab393d4be9cc9a4d8a88 Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 4 Sep 2020 13:29:40 -0600 Subject: [PATCH 11/12] merged upstream develop and added entry to CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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! From 8029d649ee7879631bcb4b8853aa6450e99528ab Mon Sep 17 00:00:00 2001 From: Chris Helm Date: Fri, 4 Sep 2020 13:54:48 -0600 Subject: [PATCH 12/12] flake style fixes --- tests/extensions/test_pointcloud.py | 33 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/extensions/test_pointcloud.py b/tests/extensions/test_pointcloud.py index e8fae73b4..ac90a3ba5 100644 --- a/tests/extensions/test_pointcloud.py +++ b/tests/extensions/test_pointcloud.py @@ -25,11 +25,12 @@ def test_apply(self): item.ext.pointcloud item.ext.enable(Extensions.POINTCLOUD) - item.ext.pointcloud.apply(1000, 'lidar', 'laszip', [PointcloudSchema({ - 'name': 'X', - 'size': 8, - 'type': 'floating' - })]) + 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) @@ -112,16 +113,18 @@ def test_statistics(self): 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 - })] + 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'])