• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KU-Leuven-Geomatics / geomapi / 15206886475

23 May 2025 09:21AM UTC coverage: 46.863% (-0.2%) from 47.067%
15206886475

push

github

web-flow
Geomapi 1.0 (#24)

* Maarten Update

* Small PCD

* test_req update

* requirements update

* railway update

* big update

* no more resources

* type hinting docs build

* Docs update

* Utils audit

* test_req_update

* utils audit

* update

* refactoring serialization and ontology update

* Node refactor test success

* Node refactoring

* Node refactoring, only hte image node remains...

* Pre Tools Overhaul

* Tools overhaul + tests

* remove old code

* Test complete??

* removed as_open3d

* Docs Update

* docs Update

* Docs Update

* setNode show function

* ver 0.9

* test Update

* Documentaion and imageNode focallenght

* TestFixes and docs update

* xml_to_imageNodes fix

---------

Co-authored-by: Jelle Vermandere <45608714+Jellevermandere@users.noreply.github.com>

3175 of 6775 relevant lines covered (46.86%)

0.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.92
geomapi/nodes/pointcloudnode.py
1
"""
2
**PointCloudNode** is the Node class that governs the data and metadata of point cloud data (Open3D, E57, LAS).
3

4
.. image:: ../../../docs/pics/graph_pcds2.png
5

6
This node builds upon the [Open3D](https://www.open3d.org/), [PYE57](https://github.com/davidcaron/pye57) and [LASPY](https://laspy.readthedocs.io/en/latest/) API for the point cloud definitions. 
7
"""
8
#IMPORT PACKAGES
9
from typing import Optional
1✔
10
import xml.etree.ElementTree as ET
1✔
11
from xmlrpc.client import Boolean 
1✔
12
import open3d as o3d 
1✔
13
import numpy as np 
1✔
14
import os
1✔
15
from scipy.spatial.transform import Rotation as R
1✔
16
from rdflib import XSD, Graph, URIRef
1✔
17
import pye57 
1✔
18
import laspy 
1✔
19
from pathlib import Path
1✔
20

21
import trimesh
1✔
22

23
#IMPORT MODULES
24
# from geomapi.nodes import GeometryNode
25
from geomapi.nodes import Node
1✔
26
import geomapi.utils as ut
1✔
27
from geomapi.utils import rdf_property, GEOMAPI_PREFIXES
1✔
28
import geomapi.utils.geometryutils as gmu
1✔
29

30
class PointCloudNode (Node):
1✔
31
    def __init__(self, 
1✔
32
                subject: Optional[URIRef] = None,
33
                graph: Optional[Graph] = None,
34
                graphPath: Optional[Path] = None,
35
                name: Optional[str] = None,
36
                path: Optional[Path] = None,
37
                timestamp: Optional[str] = None,
38
                resource = None,
39
                cartesianTransform: Optional[np.ndarray] = None,
40
                orientedBoundingBox: Optional[o3d.geometry.OrientedBoundingBox] = None,
41
                convexHull: Optional[o3d.geometry.TriangleMesh] =None,
42
                loadResource: bool = False,
43
                e57Index: int = None,
44
                **kwargs):
45
        """
46
        Creates a PointCloudNode. Overloaded function.
47
        
48
        This Node can be initialised from one or more of the inputs below.
49
        By default, no data is imported in the Node to speed up processing.
50
        If you also want the data, call node.get_resource() or set getResource() to True.
51
        
52
        Args:
53
            - graph (RDFlib Graph) : Graph with a single subject (if multiple subjects are present, only the first will be used to initialise the Node)
54
            
55
            - graphPath (Path) :  Graph file path with a single subject (if multiple subjects are present, only the first will be used to initialise the Node)
56

57
            - path (Path) : path to .pcd, .e57, .las or .laz file (data is not automatically loaded)
58
            
59
            - subject (URIRef, optional) : A subject to use as identifier for the Node. If a graph is also present, the subject should be part of the graph.
60

61
            - e57Index (int) : index of the scan you want to import from an e57 file. Defaults to 0.
62

63
            - resource (o3d.geometry.PointCloud) : Open3D point cloud data parsed from an e57, las or pcd class.
64
           
65
            - getResource (bool, optional= False) : If True, the node will search for its physical resource on drive 
66
                            
67
        Returns:
68
            pointcloudnode : A pointcloudnode with metadata 
69
        """          
70
        
71
        # properties
72
        self.e57Index = e57Index
1✔
73
        # self.e57XmlPath = ut.parse_path(e57XmlPath)
74

75
        super().__init__(   subject = subject,
1✔
76
                    graph = graph,
77
                    graphPath = graphPath,
78
                    name = name,
79
                    path = path,
80
                    timestamp = timestamp,
81
                    resource = resource,
82
                    cartesianTransform = cartesianTransform,
83
                    orientedBoundingBox = orientedBoundingBox,
84
                    convexHull = convexHull,
85
                    loadResource = loadResource,
86
                    **kwargs)  
87

88
        
89
        
90

91
#---------------------PROPERTIES----------------------------
92

93
    #---------------------pointCount----------------------------
94
    @property
1✔
95
    @rdf_property(datatype=XSD.int)
1✔
96
    def pointCount(self):
1✔
97
        if self.resource:
1✔
98
            return len(np.asarray(self.resource.points))
1✔
99
        else: 
100
            return self._pointCount
1✔
101
    
102
    @pointCount.setter
1✔
103
    def pointCount(self, value):
1✔
104
        if self.resource:
1✔
105
            print("PointCount cannot be set directly when a resource is present")
1✔
106
        self._pointCount = value
1✔
107

108
    #---------------------e57Index----------------------------
109
    @property
1✔
110
    def e57Index(self): 
1✔
111
        """(int) value of the e57Index of an e57 file. Defaults to 0. 
112
        
113
        Raises:
114
            ValueError: e57Index should be positive integer.
115
        """
116
        if self._e57Index:
1✔
117
            pass 
1✔
118
        else:
119
            self._e57Index=0
1✔
120
        return self._e57Index
1✔
121

122
    @e57Index.setter
1✔
123
    def e57Index(self,value:int):
1✔
124
        if value is None:
1✔
125
            self._e57Index = None
1✔
126
        elif int(value) >=0:
1✔
127
            self._e57Index=int(value)
1✔
128
        else:
129
            raise ValueError('e57Index should be positive integer.')
×
130

131

132
#---------------------PROPERTY OVERRIDES----------------------------
133

134
    @Node.resource.setter
1✔
135
    def resource(self, value):  
1✔
136
        """Set the self.resource (o3d.geometry.PointCloud) of the Node.
137

138
        Args:
139
            - open3d.geometry.PointCloud
140
            - pye57.e57.E57 instance
141
            - pye57 dict instance
142
            - laspy las file
143

144
        Raises:
145
            ValueError: Resource type not supported or len(resource.points) <1.
146
        """
147
        if value is None:
1✔
148
            self._resource = None
1✔
149
        elif isinstance(value,o3d.geometry.PointCloud) and len(value.points) >=1:
1✔
150
            self._resource = value
1✔
151
        elif isinstance(value,pye57.e57.E57):
1✔
152
            self._resource=gmu.e57_to_pcd(value,self.e57Index)
1✔
153
        elif isinstance(value,dict):
1✔
154
            self._resource=gmu.e57_dict_to_pcd(value)
1✔
155
        elif isinstance(value,laspy.lasdata.LasData):
1✔
156
            self._resource=gmu.las_to_pcd(value)
1✔
157
        else:
158
            raise ValueError('Resource type not supported or len(resource.points) <3.')
1✔
159

160
#---------------------METHODS----------------------------
161

162
    def _set_metadata_from_e57_header(self, cartesianTransform = None, orientedBoundingBox = None, convexHull = None) -> bool:
1✔
163
        """Sets the metadata from an e57 header. 
164

165
        Args:
166
            - pointCount
167
            - name
168
            - subject
169
            - cartesianTransform
170
            - cartesianBounds
171
            - orientedBoundingBox
172

173
        Returns:
174
            bool: True if meta data is successfully parsed
175
        """ 
176

177
        #TODO dit zorgt voor conflicten aangezien deze waarden altijd een standaardwaarde krijgen in de Node class 
178
        if self.graph:
1✔
179
            print("Graph is already defined, no need to parse values")
×
180
            return True
×
181
        
182
        #if self.cartesianTransform is not None and self.convexHull is not None and self.orientedBoundingBox is not None:
183
        #    return True
184

185
        # try:
186
        e57 = pye57.E57(str(self.path))   
1✔
187
        header = e57.get_header(self.e57Index)
1✔
188
        
189
        if 'name' in header.scan_fields:
1✔
190
            self.name=header['name'].value()
1✔
191
            self.subject=self.name
1✔
192
        
193
        if 'pose' in header.scan_fields and cartesianTransform is None:
1✔
194
            rotation_matrix=None
1✔
195
            translation=None
1✔
196
            if getattr(header,'rotation',None) is not None:
1✔
197
                rotation_matrix=header.rotation
1✔
198
            if getattr(header,'translation',None) is not None:
1✔
199
                translation=header.translation
1✔
200
            self.cartesianTransform=gmu.get_cartesian_transform(rotation=rotation_matrix,translation=translation)
1✔
201
        if 'cartesianBounds' in header.scan_fields and orientedBoundingBox is None:
1✔
202
            c=header.cartesianBounds
1✔
203
            cartesianBounds=np.array([c["xMinimum"].value(),
1✔
204
                                            c["xMaximum"].value(), 
205
                                            c["yMinimum"].value(),
206
                                            c["yMaximum"].value(),
207
                                            c["zMinimum"].value(),
208
                                            c["zMaximum"].value()])   
209
            #construct 8 bounding points from cartesianBounds
210
            points=gmu.get_oriented_bounds(cartesianBounds)
1✔
211
            self.orientedBoundingBox=points
1✔
212
            if(convexHull is None): self.convexHull=points
1✔
213
            
214
        if 'points' in header.scan_fields:
1✔
215
            self.pointCount=header.point_count
1✔
216
        #     return True
217
        # except:
218
        #     raise ValueError('e57 header parsing error. perhaps missing scan_fields/point_fields?')
219

220
    def _set_geometric_properties(self, _cartesianTransform=None, _convexHull=None, _orientedBoundingBox=None):
1✔
221
        
222
        #initialisation
223
        if (self.path and self.path.suffix =='.e57'):
1✔
224
            self._set_metadata_from_e57_header(cartesianTransform = _cartesianTransform, orientedBoundingBox = _orientedBoundingBox, convexHull = _convexHull)
1✔
225
        
226
        super()._set_geometric_properties(_cartesianTransform if self.cartesianTransform is None else self.cartesianTransform, 
1✔
227
                                          _convexHull if self.convexHull is None else self.convexHull, 
228
                                          _orientedBoundingBox if self.orientedBoundingBox is None else self.orientedBoundingBox)
229

230
    def _transform_resource(self, transformation: np.ndarray, rotate_around_center: bool):
1✔
231
        """
232
        Apply a transformation to the pointcloud resource.
233

234
        If rotate_around_center is True, the transformation is applied about the mesh's center.
235
        Otherwise, the transformation is applied as-is.
236

237
        Args:
238
            transformation (np.ndarray): A 4x4 transformation matrix.
239
            rotate_around_center (bool): Whether to rotate around the mesh's center.
240
        """
241
        if rotate_around_center:
1✔
242
            center = self.resource.get_center()
1✔
243
            t1 = np.eye(4)
1✔
244
            t1[:3, 3] = -center
1✔
245
            t2 = np.eye(4)
1✔
246
            t2[:3, 3] = center
1✔
247
            transformation = t2 @ transformation @ t1
1✔
248
        self.resource.transform(transformation)
1✔
249

250
    def load_resource(self, percentage:float=1.0) -> o3d.geometry.PointCloud:
1✔
251
        """Returns the pointcloud data in the node. If none is present, it will search for the data on drive from path, graphPath, name or subject. 
252

253
        Args:
254
            - percentage (float,optional) : percentage of point cloud to load. Defaults to 1.0 (100%).
255

256
        Returns:
257
            o3d.geometry.PointCloud or None
258
        """
259
        # Perform path checks
260
        super().load_resource()
1✔
261

262
        if self.path:
1✔
263
            if self.path.suffix == '.pcd':
1✔
264
                resource =  o3d.io.read_point_cloud(str(self.path))
1✔
265
                resource = resource.random_down_sample(percentage)
1✔
266
                self.resource = resource
1✔
267
            elif self.path.suffix == '.e57':
1✔
268
                self.resource = gmu.e57path_to_pcd(self.path, self.e57Index,percentage=percentage) 
1✔
269
            elif self.path.suffix == '.las' or self.path.suffix == '.laz':
1✔
270
                las=laspy.read(self.path)
1✔
271
                self.resource = gmu.las_to_pcd(las,getColors=True,getNormals=True)                
1✔
272
        return self._resource  
1✔
273

274
    def save_resource(self, directory:Path | str=None,extension :str = '.pcd') ->bool:
1✔
275
        """Export the resource of the Node.
276

277
        Args:
278
            - directory (str, optional) : directory folder to store the data.
279
            - extension (str, optional) : file extension. Defaults to '.pcd'.
280

281
        Raises:
282
            ValueError: Unsuitable extension. Please check permitted extension types in utils._init_.
283

284
        Returns:
285
            bool: return True if export was succesful
286
        """        
287
        # perform the path check and create the directory
288
        if not super().save_resource(directory, extension):
1✔
289
            return False
1✔
290

291
        #write files
292
        if self.path.suffix == '.e57':
1✔
293
            data3D=gmu.get_data3d_from_pcd(self.resource)
×
294
            rotation=np.array([1,0,0,0])
×
295
            translation=np.array([0,0,0])
×
296
            with pye57.E57(self.path, mode="w") as e57_write:
×
297
                e57_write.write_scan_raw(data3D, rotation=rotation, translation=translation) 
×
298
        elif self.path.suffix == '.pcd':
1✔
299
            o3d.io.write_point_cloud(str(self.path), self.resource)
1✔
300
        elif self.path.suffix == '.las':
×
301
            las= gmu.pcd_to_las(self.resource)
×
302
            las.write(self.path)
×
303
        else:
304
            return False
×
305
        return True
1✔
306
    
307
    def show(self,  inline = False):
1✔
308
        super().show()
×
309
        if(inline):
×
310
            from IPython.display import display
×
311
            display(trimesh.Scene(gmu.mesh_to_trimesh(self.resource)).show())
×
312
        else:
313
            gmu.show_geometries([self.resource])
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc