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

KU-Leuven-Geomatics / geomapi / 15188762246

22 May 2025 02:06PM UTC coverage: 46.454% (-0.6%) from 47.067%
15188762246

push

github

Jellevermandere
Documentaion and imageNode focallenght

3144 of 6768 relevant lines covered (46.45%)

0.46 hits per line

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

45.11
geomapi/utils/geometryutils.py
1
"""
2
Geometryutils - a Python library for processing mesh and point cloud data.
3
"""
4
import concurrent.futures
1✔
5
import copy
1✔
6
import math
1✔
7
from typing import List, Tuple
1✔
8
import pandas as pd
1✔
9
import sys
1✔
10
import itertools
1✔
11
from pathlib import Path
1✔
12
from sklearn.neighbors import NearestNeighbors # to compute nearest neighbors
1✔
13
import laspy # this is to process las point clouds
1✔
14
import cv2
1✔
15
import geomapi.utils as ut
1✔
16
from pathlib import Path 
1✔
17

18
import geomapi.utils.imageutils as iu #! this might be a problem
1✔
19

20
import ifcopenshell
1✔
21
import ifcopenshell.geom as geom
1✔
22
import numpy as np
1✔
23
import open3d as o3d
1✔
24
import pye57
1✔
25
import trimesh
1✔
26
from scipy.spatial.transform import Rotation as R
1✔
27

28
pye57.e57.SUPPORTED_POINT_FIELDS.update({'nor:normalX' : 'd','nor:normalY': 'd','nor:normalZ': 'd'})
1✔
29

30
def array_to_colors(array: np.array, colors:np.array=None) -> np.array:
1✔
31
    """Map colors according to the unique values in the array.\n
32

33
    Args:
34
        1. array (np.array(n,1)): array with scalars e.g. predictions.\n
35
        2. colors (np.array(n,3)): e.g. np.array([[1,0,0],[0,1,0]]). colors are automatically mapped from [0,1].\n
36

37
    Returns:
38
        np.array(n,3)
39
    """
40
    values=np.unique(array)
×
41

42
    #validate inputs
43
    colors=colors if colors is not None else np.array([ut.get_random_color() for v in range(len(values))])
×
44
    assert colors.shape[1] == 3, f'colors.shape[1] != 3, got {colors.shape[1]}'
×
45
    assert colors.shape[0] == len(values),f'colors.shape[1] != values.shape[1], got {values.shape[1]}'
×
46
    colors=np.c_[colors/255]  if np.amax(colors)>1 else colors
×
47
        
48
    #map colors
49
    colorArray=np.empty((array.shape[0],3))
×
50
    for v,c in zip(values,colors):    
×
51
        colorArray[array==v]=c
×
52
    return colorArray
×
53

54
def array_subsample(array:np.ndarray,percentage:float=0.1)->np.ndarray:
1✔
55
    """subsample rows of np.array
56

57
    Args:
58
        array (np.ndarray): 
59
        percentage (float, optional): downsampling percentage. Defaults to 0.1.
60

61
    Returns:
62
        np.ndarray: output array
63
    """
64
    #create mask of True and False
65
    size=math.ceil(array.shape[0]*percentage)
×
66
    choice = np.random.choice(range(array.shape[0]), size=(size,), replace=False)    
×
67
    ind = np.zeros(array.shape[0], dtype=bool)
×
68
    ind[choice] = True
×
69
    
70
    #mask the array
71
    return array[ind,:] 
×
72

73
def arrays_to_mesh(tuple) -> o3d.geometry.TriangleMesh:
1✔
74
    """Returns TriangleMesh from arrays.\n
75

76
    Args:
77
        tuple (Tuple): \n
78
            1. vertexArray:np.array \n
79
            2. triangleArray:np.array \n
80
            3. (optional) colorArray:np.array \n
81
            4. (optional) normalArray:np.array \n
82

83
    Returns:
84
        o3d.geometry.PointCloud
85
    """ 
86
    if (len(tuple) < 2):
1✔
87
        raise ValueError("The tuple should contain at least verteces and triangles")
×
88
    mesh = o3d.geometry.TriangleMesh()
1✔
89
    mesh.vertices = o3d.utility.Vector3dVector(tuple[0])
1✔
90
    mesh.triangles = o3d.utility.Vector3iVector(tuple[1])
1✔
91

92
    if len(tuple) > 2:
1✔
93
        mesh.vertex_colors = o3d.utility.Vector3dVector(tuple[2])
1✔
94
    if len(tuple) > 3:
1✔
95
        mesh.vertex_normals = o3d.utility.Vector3dVector(tuple[3])
1✔
96
    return mesh
1✔
97

98
def box_to_mesh(box:o3d.geometry) ->o3d.geometry.TriangleMesh:
1✔
99
    """Returns o3d.geometry.TriangleMesh of an OrientedBoundingBox or AxisAlignedBoundingBox. \n
100

101
    Args:
102
        box (o3d.geometry.OrientedBoundingBox or AxisAlignedBoundingBox).
103

104
    Returns:
105
        o3d.geometry.TriangleMesh
106
    """
107
    mesh=o3d.geometry.TriangleMesh()
1✔
108
    mesh.vertices=box.get_box_points()
1✔
109
    #triangles rotate counterclockwise
110
    mesh.triangles= o3d.utility.Vector3iVector(np.array([[0,2,1],
1✔
111
                        [0,1,3],
112
                        [0,3,2],
113
                        [1,6,3],
114
                        [1,7,6],
115
                        [1,2,7],
116
                        [2,3,5],
117
                        [2,5,4],
118
                        [2,4,7],
119
                        [3,4,5],
120
                        [3,6,4],
121
                        [4,6,7]])) 
122
    return mesh 
1✔
123
                  
124

125
def color_by_intensity(pcd:o3d.geometry.PointCloud, intensities:np.array) -> o3d.geometry.PointCloud:
1✔
126
    """ Colorize a o3d.geometry.PointCloud with a numpy array of intesities.
127
    The intensties are assumed to have a maximum value of 65535.
128
    
129
    Args:
130
        1. pcd (o3d.geometry.PointCloud): Point Cloud to colorize.
131
        2. intensities (ndarray): (mx1) values [0-65535].
132
        
133
    Returns
134
        o3d.geometry.PointCloud     
135
    """
136
    assert intensities.shape[0] == np.asarray(pcd.points).shape[0], f'length intensities ({intensities.shape[0]}) differs from pcd ({np.asarray(pcd.points).shape[0]})'
×
137
    
138
    # If the intensity array is RGB encoded, first normalize it using opencv to 16-bit precision
139
    intensities_norm = cv2.normalize(intensities, dst=None, alpha=0, beta=65535, norm_type=cv2.NORM_MINMAX)
×
140

141
    # Using numpy.column_stack() provide equal values to RGB values and then assign to 'colors' property 
142
    # of the point cloud.
143
    # Since Open3D point cloud's 'colors' property is float64 array of shape (num_points, 3), range [0, 1],
144
    # we have to normalize the intensity array by dividing it by 65535
145
    colors = np.column_stack((intensities_norm, intensities_norm, intensities_norm)) / 65535
×
146

147
    # Now colors array is grayscale array (r = g = b)
148
    pcd.colors = o3d.utility.Vector3dVector(colors)
×
149
    return pcd
×
150

151
def compute_nearest_neighbors(query_points:np.ndarray,
1✔
152
                              reference_points:np.ndarray,
153
                              query_normals:np.ndarray = None, 
154
                              reference_normals:np.ndarray = None, 
155
                              n:int=5,
156
                              distanceThreshold=None)->Tuple[np.ndarray,np.ndarray]:
157
    """Compute index and distance to nearest neighboring point in the reference dataset.\n
158
    if the normals are given, it uses them to apply a normal filtering
159
    For the normal filtering, the n closest neighbors are considered of which the correspondence with the best matching normal is retained. \n
160

161
    **NOTE**:  The index of outliers is set to -1 if distanceTreshold is not None.\n
162
    
163
    Args:
164
        1. query_points (np.array[n,3]): points to evaluate.\n
165
        2. query_normals (np.array[n,3]): normals to evaluate.\n
166
        3. reference_points (np.array[n,3]): reference points.\n
167
        4. reference_normals (np.array[n,3]): reference normals.\n
168
        5. n (int, optional): number of neighbors.\n
169
        5. distanceTreshold (_type_, optional): Distance threshold for the nearest neighbors.. Defaults to None.
170

171
    Returns:
172
        Tuple[np.array,np.array]: indices, distances 
173
    """
174
    #compute nearest neighbors 
175
    nbrs = NearestNeighbors(n_neighbors=n, algorithm='kd_tree').fit(reference_points)
1✔
176
    distances,indices, = nbrs.kneighbors(query_points)
1✔
177
    # apply normal filtering if the normals are given
178
    if(query_normals is not None and reference_normals is not None):
1✔
179
        #compute dotproduct
180
        dotproducts=np.empty((indices.shape))
×
181
        for i,ind in enumerate(np.hsplit(indices,indices.shape[1])):
×
182
            dotproducts[:, i]= np.einsum('ij,ij->i', np.take(reference_normals, ind.flatten().T, axis=0), query_normals)
×
183
        #select index with highest dotproduct
184
        ind=np.argmax(np.absolute(dotproducts), axis=1)
×
185
        indices = indices[np.arange(indices.shape[0]), ind]
×
186
        distances = distances[np.arange(distances.shape[0]), ind]        
×
187
        
188
        #filter distances   
189
        if distanceThreshold is not None:
×
190
            assert distanceThreshold>0 ,f'distanceTreshold should be positive, got {distanceThreshold}'
×
191
            indices=np.where(distances>distanceThreshold,-1,indices)
×
192
            distances=np.where(distances>distanceThreshold,-1,distances)  
×
193
        
194
    return indices,distances
1✔
195

196
def compute_raycasting_collisions(geometries:List[o3d.geometry.Geometry],rays:np.array)->Tuple[np.array,np.array]:
1✔
197
    """Compute the collisions between a set of Open3D geometries and rays.\n
198

199
    .. image:: ../../../docs/pics/Raycasting_1.PNG
200
    .. image:: ../../../docs/pics/boxes1.PNG
201
    
202
    Args:
203
        1.geometries (List[o3d.geometry.Geometry]): A set of Point clouds or meshes. \n
204
        2.rays (np.array[m,n,6]): Set of rays (1D or 2D) with 6 columns as the last dimension. A ray consists of a startpoint np.array[n,0:3] and a direction np.array[n,3:6]\n
205

206
    Returns:
207
        Tuple[np.array,np.array]: distances (inf if not hit), geometry_ids (4294967295 if not hit)
208
    """
209
    #validate rays
210
    if 'Tensor' in str(type(rays)):
×
211
        assert rays.shape[-1]==6, f'rays.shape[-1] should be 6, got {rays.shape[1]}.'
×
212
    if 'array' in str(type(rays)):        
×
213
        assert rays.shape[-1]==6, f'rays.shape[-1] should be 6, got {rays.shape[1]}.'
×
214
        rays = o3d.core.Tensor(rays,dtype=o3d.core.Dtype.Float32)  
×
215
      
216
    #create raycasting scene
217
    scene = o3d.t.geometry.RaycastingScene()
×
218

219
    #validate geometries and add them to the scene
220
    for g in ut.item_to_list(geometries):
×
221
        reference=o3d.t.geometry.TriangleMesh.from_legacy(g)
×
222
        scene.add_triangles(reference)
×
223
    
224
    #compute raycasting
225
    ans = scene.cast_rays(rays)
×
226
    return ans['t_hit'].numpy(),ans['geometry_ids'].numpy()
×
227

228
def convert_to_homogeneous_3d_coordinates(input_data: list | np.ndarray) -> np.ndarray:
1✔
229
    """
230
    Converts 3D Cartesian coordinates into homogeneous coordinates or normalizes 
231
    existing homogeneous coordinates.
232

233
    Args:
234
        input_data (list or numpy.ndarray): The input data representing 3D coordinates.
235
                                            Each row should have either 3 (Cartesian) 
236
                                            or 4 (homogeneous) elements.
237

238
    Returns:
239
        numpy.ndarray: A 2D NumPy array where:
240
            - If input has 3 columns, a fourth column of ones is added.
241
            - If input has 4 columns, all elements are normalized by the last column.
242
            - Otherwise, a ValueError is raised.
243
    """
244
    # Convert to 2D array
245
    input_data = ut.map_to_2d_array(input_data)
1✔
246

247
    # Convert Cartesian coordinates to homogeneous
248
    if input_data.shape[1] == 3:
1✔
249
        homogeneous_column = np.ones((input_data.shape[0], 1))
1✔
250
        input_data = np.hstack((input_data, homogeneous_column))
1✔
251
    elif input_data.shape[1] == 4:
1✔
252
        # Normalize by the last coordinate
253
        input_data = input_data / input_data[:, -1][:, np.newaxis]
1✔
254
    else:
255
        raise ValueError("Each coordinate should have either 3 or 4 elements.")
×
256

257
    return input_data
1✔
258

259
def create_camera_frustum_mesh(transform, focal_length_35mm, depth=5.0):
1✔
260
    """
261
    Creates a 3D camera frustum mesh as a triangular pyramid using 35mm-equivalent focal length.
262

263
    Parameters:
264
    -----------
265
    transform : np.ndarray, shape (4, 4)
266
        A 4x4 transformation matrix that places the camera frustum in world coordinates.
267

268
    focal_length_35mm : float
269
        Camera focal length in millimeters assuming a standard full-frame (36mm x 24mm) sensor.
270
        Used to compute the field of view of the frustum.
271

272
    depth : float, optional (default=5.0)
273
        The distance from the camera origin to the far image plane along the Z-axis.
274

275
    Returns:
276
    --------
277
    mesh : o3d.geometry.TriangleMesh
278
        A triangular mesh representing the camera frustum as a 3D pyramid.
279

280
    Notes:
281
    ------
282
    - The frustum is constructed with its apex at the origin [0, 0, 0] and a rectangular base at the given depth.
283
    - The field of view is calculated from the sensor size and focal length.
284
    - The mesh includes triangular sides and a back face (the image plane), and is colored uniformly.
285
    - The result is useful for visualizing camera poses and orientations in 3D.
286

287
    Example:
288
    --------
289
    frustum_mesh = create_camera_frustum_mesh(np.eye(4), focal_length_35mm=50.0)
290
    o3d.visualization.draw_geometries([frustum_mesh])
291
    """
292
    # Standard full-frame sensor size (in mm)
293
    sensor_width = 36.0
1✔
294
    sensor_height = 24.0
1✔
295

296
    # Compute field of view based on 35mm focal length
297
    fov_x = 2 * math.atan((sensor_width / 2) / focal_length_35mm)
1✔
298
    fov_y = 2 * math.atan((sensor_height / 2) / focal_length_35mm)
1✔
299

300
    # Calculate half-width and half-height at far plane
301
    half_width = depth * math.tan(fov_x / 2)
1✔
302
    half_height = depth * math.tan(fov_y / 2)
1✔
303

304
    # Define vertices of the frustum pyramid
305
    vertices = np.array([
1✔
306
        [0, 0, 0],  # Camera origin
307

308
        [-half_width, -half_height, depth],  # Far bottom-left
309
        [ half_width, -half_height, depth],  # Far bottom-right
310
        [ half_width,  half_height, depth],  # Far top-right
311
        [-half_width,  half_height, depth],  # Far top-left
312
    ])
313

314
    # Define triangles (faces of the pyramid)
315
    triangles = [
1✔
316
        [0, 1, 2],  # Bottom face
317
        [0, 2, 3],  # Right face
318
        [0, 3, 4],  # Top face
319
        [0, 4, 1],  # Left face
320
        [1, 2, 3], [1, 3, 4],  # Back (far) face (optional)
321
    ]
322

323
    mesh = o3d.geometry.TriangleMesh()
1✔
324
    mesh.vertices = o3d.utility.Vector3dVector(vertices)
1✔
325
    mesh.triangles = o3d.utility.Vector3iVector(triangles)
1✔
326

327
    # Optional: set color
328
    mesh.paint_uniform_color([0.8, 0.2, 0.2])
1✔
329

330
    # Optional: compute normals
331
    mesh.compute_vertex_normals()
1✔
332

333
    # Transform to world space
334
    mesh.transform(transform)
1✔
335

336
    return mesh
1✔
337

338
def create_camera_frustum_mesh_with_image(transform, image_width, image_height, focal_length_35mm, depth=5.0, image_cv2=None):
1✔
339
    """
340
    Creates a 3D camera frustum as an Open3D LineSet, and optionally a textured image plane at the frustum's far end.
341

342
    Parameters:
343
    -----------
344
    transform : np.ndarray, shape (4, 4)
345
        A 4x4 transformation matrix representing the camera's pose in world coordinates.
346

347
    image_width : int
348
        Width of the image in pixels. Used to determine the aspect ratio of the camera's sensor.
349

350
    image_height : int
351
        Height of the image in pixels. Used to determine the aspect ratio of the camera's sensor.
352

353
    focal_length_35mm : float
354
        Camera focal length in millimeters, assuming a 35mm-equivalent sensor width of 36mm.
355
        This is used to compute the field of view and shape of the frustum.
356

357
    depth : float, optional (default=5.0)
358
        Distance from the camera origin to the far/image plane along the Z-axis (i.e., frustum depth).
359

360
    image_cv2 : np.ndarray, optional
361
        An OpenCV (NumPy) RGB or BGR image. If provided, a textured mesh is created at the far end
362
        of the frustum using the image dimensions for accurate aspect ratio.
363

364
    Returns:
365
    --------
366
    line_set : o3d.geometry.LineSet
367
        A LineSet representing the frustum's edges.
368

369
    image_mesh : o3d.geometry.TriangleMesh or None
370
        A textured TriangleMesh showing the input image at the far end of the frustum.
371
        Returns None if `image_cv2` is not provided.
372

373
    Notes:
374
    ------
375
    - The frustum assumes a perspective camera with a virtual sensor width of 36mm.
376
    - The image plane is placed at `depth` units away from the camera origin along the local Z-axis.
377
    - This function is useful for visualizing camera poses and projected views in 3D scenes.
378

379
    Example:
380
    --------
381
    frustum, img_plane = create_camera_frustum_mesh(transform, w, h, 50.0, depth=5.0, image_cv2=cv2_img)
382
    o3d.visualization.draw_geometries([frustum, img_plane] if img_plane else [frustum])
383
    """
384
    
385
    # Use 36mm width as the base (standard full-frame width)
386
    sensor_width_mm = 36.0
×
387
    sensor_aspect = image_height / image_width
×
388
    sensor_height_mm = sensor_width_mm * sensor_aspect
×
389

390
    # Compute field of view in radians
391
    fov_x = 2 * math.atan((sensor_width_mm / 2) / focal_length_35mm)
×
392
    fov_y = 2 * math.atan((sensor_height_mm / 2) / focal_length_35mm)
×
393

394
    # Compute half width and height at the given depth
395
    half_width = depth * math.tan(fov_x / 2)
×
396
    half_height = depth * math.tan(fov_y / 2)
×
397

398
    # Define frustum vertices
399
    vertices = np.array([
×
400
        [0, 0, 0],  # Camera origin
401

402
        [-half_width, -half_height, depth],  # Far bottom-left
403
        [ half_width, -half_height, depth],  # Far bottom-right
404
        [ half_width,  half_height, depth],  # Far top-right
405
        [-half_width,  half_height, depth],  # Far top-left
406
    ])
407

408
    # Frustum wireframe connections
409
    lines = [
×
410
        [0, 1], [0, 2], [0, 3], [0, 4],
411
        [1, 2], [2, 3], [3, 4], [4, 1],
412
    ]
413

414
    line_set = o3d.geometry.LineSet()
×
415
    line_set.points = o3d.utility.Vector3dVector(vertices)
×
416
    line_set.lines = o3d.utility.Vector2iVector(lines)
×
417
    line_set.colors = o3d.utility.Vector3dVector([[0, 0, 1] for _ in lines])  # Blue
×
418

419
    # Apply transformation
420
    line_set.transform(transform)
×
421

422
    image_mesh = None
×
423
    if image_cv2 is not None:
×
424
        # Get image dimensions and aspect ratio
425
        img_height, img_width = image_cv2.shape[:2]
×
426
        aspect_ratio = img_width / img_height
×
427

428
        # Match the frustum's field of view while preserving aspect ratio
429
        img_plane_half_height = half_height
×
430
        img_plane_half_width = img_plane_half_height * aspect_ratio
×
431

432
        # Image plane vertices (quad)
433
        plane_vertices = np.array([
×
434
            [img_plane_half_width, -img_plane_half_height, depth],  # Bottom-left
435
            [ -img_plane_half_width, -img_plane_half_height, depth],  # Bottom-right
436
            [ -img_plane_half_width,  img_plane_half_height, depth],  # Top-right
437
            [img_plane_half_width,  img_plane_half_height, depth],  # Top-left
438
        ])
439

440
        triangles = np.array([
×
441
            [0, 1, 2],
442
            [0, 2, 3],
443
        ])
444

445
        # create the uv coordinates
446
        v_uv = np.array([[0, 1], [1, 1], [1, 0], 
×
447
                 [0, 1], [1, 0], [0, 0]])
448

449
        # Convert OpenCV image (BGR to RGB if needed)
450
        o3d_image = o3d.geometry.Image(image_cv2)
×
451

452
        image_mesh = o3d.geometry.TriangleMesh()
×
453
        image_mesh.vertices = o3d.utility.Vector3dVector(plane_vertices)
×
454
        image_mesh.triangles = o3d.utility.Vector3iVector(triangles)
×
455
        image_mesh.triangle_uvs = o3d.utility.Vector2dVector(v_uv)
×
456
        image_mesh.triangle_material_ids = o3d.utility.IntVector([0] * len(triangles))
×
457
        image_mesh.textures = [o3d_image]
×
458
        image_mesh.compute_vertex_normals()
×
459
        image_mesh.transform(transform)
×
460

461
    return line_set, image_mesh
×
462

463
def create_ellipsoid_mesh(radii: np.ndarray, transformation: np.ndarray, resolution: int = 30):
1✔
464
    """
465
    Create an Open3D TriangleMesh of an ellipsoid with a given set of radii and transformation matrix.
466

467
    .. image:: ../../../docs/pics/ellipsoid.jpg
468

469
    Args:
470
        - radii: A numpy array [a,b,c] representing the radii of the ellipsoid along the primary, secondary and tertiary axes.
471
        - transformation: A 4x4 transformation matrix (numpy array) to apply to the ellipsoid mesh.
472
        - resolution: The resolution of the mesh (default 30).
473
    
474
    Returns
475
        - An Open3D TriangleMesh object representing the ellipsoid.
476
    """
477
    # Create a parametric grid for the ellipsoid
478
    u = np.linspace(0, 2 * np.pi, resolution)  # azimuth angle
×
479
    v = np.linspace(0, np.pi, resolution)      # polar angle
×
480
    u, v = np.meshgrid(u, v)
×
481

482
    # Parametric equations of an ellipsoid
483
    a = radii[0] * np.sin(v) * np.cos(u)
×
484
    b = radii[1] * np.sin(v) * np.sin(u)
×
485
    c = radii[2] * np.cos(v)
×
486

487
    # Flatten the arrays and stack them into Nx3 shape for vertices
488
    vertices = np.stack((a.flatten(), b.flatten(), c.flatten()), axis=-1)
×
489

490
    # Create faces (triangles) using the mesh grid
491
    faces = []
×
492
    for i in range(resolution - 1):
×
493
        for j in range(resolution - 1):
×
494
            idx1 = i * resolution + j
×
495
            idx2 = idx1 + resolution
×
496
            faces.append([idx1, idx1 + 1, idx2])
×
497
            faces.append([idx2, idx1 + 1, idx2 + 1])
×
498
    faces = np.asarray(faces)
×
499

500
    # Create the Open3D TriangleMesh object
501
    mesh = o3d.geometry.TriangleMesh()
×
502

503
    # Apply the transformation matrix to the vertices
504
    vertices_homogeneous = np.hstack((vertices, np.ones((vertices.shape[0], 1))))
×
505
    transformed_vertices = (transformation @ vertices_homogeneous.T).T[:, :3]  # Apply transformation
×
506

507
    # Set the vertices and triangles (faces)
508
    mesh.vertices = o3d.utility.Vector3dVector(transformed_vertices)
×
509
    mesh.triangles = o3d.utility.Vector3iVector(faces)
×
510

511
    # Compute the normals and update the mesh
512
    mesh.compute_vertex_normals()
×
513

514
    return mesh
×
515

516
def create_identity_point_cloud(geometries: o3d.geometry.PointCloud, resolution:float = 0.1, getNormals=False) -> Tuple[o3d.geometry.PointCloud, np.array]:
1✔
517
    """Returns a sampled point cloud colorized per object of a set of objects along with an array with an identifier for each point.
518

519
    TODO: MB also store normals 
520
    
521
    Args:
522
        1.geometries (o3d.geometry.PointCloud or o3d.geometry.TriangleMesh) \n
523
        2.resolution (float, optional): (down)sampling resolution for the point cloud. Defaults to 0.1.\n
524

525
    Raises:
526
        ValueError: Geometries must be o3d.geometry (PointCloud or TriangleMesh)
527

528
    Returns:
529
        Tuple[colorized point cloud (o3d.geometry.PointCloud), identityArray(np.array)] per geometry
530
    """
531
    geometries=ut.item_to_list(geometries)    
1✔
532
    colorArray=np.random.random((len(geometries),3))
1✔
533
    indentityArray=None
1✔
534
    identityPointCloud=o3d.geometry.PointCloud()
1✔
535

536
    for i,geometry in enumerate(geometries):
1✔
537
        if 'PointCloud' in str(type(geometry)) :
1✔
538
            pcd=geometry.voxel_down_sample(resolution)
×
539
            get_points_and_normals(pcd,getNormals=getNormals) if getNormals else None
×
540
            indentityArray=np.vstack((indentityArray,np.full((len(pcd.points), 1), i)))
×
541
            pcd.paint_uniform_color(colorArray[i])
×
542
            identityPointCloud +=pcd
×
543
            # np.concatenate((np.asarray(identityPointCloud.points),np.asarray(identityPointCloud.points)),axis=0)
544
        elif 'TriangleMesh' in str(type(geometry)):
1✔
545
            area=geometry.get_surface_area()
1✔
546
            count=int(area/(resolution*resolution))
1✔
547
            if count>0:
1✔
548
                count=count if count>0 else len(np.asarray(geometry.vertices))
1✔
549
                pcd=geometry.sample_points_uniformly(number_of_points=count, use_triangle_normal=getNormals)
1✔
550
            else:
551
                pcd=o3d.geometry.PointCloud()
×
552
                pcd.points=o3d.utility.Vector3dVector(np.array([geometry.get_center()]))
×
553
                
554
            indentityArray=np.vstack((indentityArray,np.full((len(pcd.points), 1), i)))
1✔
555
            pcd.paint_uniform_color(colorArray[i])
1✔
556
            identityPointCloud +=pcd
1✔
557
        else:
558
            print(f'{geometry} is invalid')
×
559
            continue
×
560
    indentityArray=indentityArray.flatten()
1✔
561
    indentityArray=np.delete(indentityArray,0)
1✔
562
    return identityPointCloud, indentityArray
1✔
563

564
def create_obb_from_orthophoto(cartesian_transform, image_width, image_height, gsd, depth):
1✔
565
    """
566
    Creates an oriented bounding box (OBB) for an orthographic image,
567
    with the origin at the center of the back face (z = 0) of the box.
568

569
    Parameters:
570
        cartesian_transform (np.ndarray): 4x4 transformation matrix.
571
        image_width (int): Image width in pixels.
572
        image_height (int): Image height in pixels.
573
        gsd (float): Ground sampling distance (meters per pixel).
574
        depth (float): Depth (thickness) of the image in meters.
575

576
    Returns:
577
        o3d.geometry.OrientedBoundingBox: The computed OBB.
578
    """
579
    # Convert image size from pixels to meters
580
    width_m = image_width * gsd
1✔
581
    height_m = image_height * gsd
1✔
582

583
    # Define center of back face (at z = 0)
584
    local_center = np.array([0,0, depth / 2])
1✔
585

586
    # Create box centered at that point
587
    obb = o3d.geometry.OrientedBoundingBox()
1✔
588
    obb.center = (cartesian_transform @ np.append(local_center, 1))[:3]
1✔
589
    obb.extent = np.array([width_m, height_m, depth])
1✔
590
    obb.R = cartesian_transform[:3, :3]  # Extract rotation
1✔
591

592
    return obb
1✔
593

594
def create_transform_from_pyramid_points(points, tol=1e-6):
1✔
595
    """
596
    Given 5 points of a pyramid (4 base + 1 tip, unordered),
597
    find the tip and compute a transform with:
598
    - origin at tip
599
    - forward toward base center
600
    - up aligned as closely as possible to world_up
601
    """
602
    assert points.shape == (5, 3), "Input must be a 5x3 array of 3D points."
1✔
603
    world_up = np.array([0, 1, 0])
1✔
604

605
    # Step 1: Find 4 coplanar points
606
    def are_coplanar(p1, p2, p3, p4):
1✔
607
        v1 = p2 - p1
1✔
608
        v2 = p3 - p1
1✔
609
        v3 = p4 - p1
1✔
610
        volume = np.abs(np.dot(np.cross(v1, v2), v3))
1✔
611
        return volume < tol
1✔
612

613
    coplanar_idxs = None
1✔
614
    for idxs in itertools.combinations(range(5), 4):
1✔
615
        if are_coplanar(*points[list(idxs)]):
1✔
616
            coplanar_idxs = list(idxs)
1✔
617
            break
1✔
618
    if coplanar_idxs is None:
1✔
619
        raise ValueError("No 4 coplanar points found.")
×
620

621
    # Step 2: Identify the 5th point
622
    all_idxs = set(range(5))
1✔
623
    fifth_idx = list(all_idxs - set(coplanar_idxs))[0]
1✔
624
    origin = points[fifth_idx]
1✔
625
    rect = points[coplanar_idxs]
1✔
626

627
    # Step 3: Find the edge closest to world up → use as 'up'
628
    best_up = None
1✔
629
    best_alignment = -np.inf
1✔
630
    for i in range(4):
1✔
631
        a = rect[i]
1✔
632
        b = rect[(i + 1) % 4]
1✔
633
        edge = b - a
1✔
634
        edge_norm = edge / np.linalg.norm(edge)
1✔
635
        alignment = np.abs(np.dot(edge_norm, world_up))
1✔
636
        if alignment > best_alignment:
1✔
637
            best_alignment = alignment
1✔
638
            best_up = edge_norm
1✔
639
    up = best_up
1✔
640

641
    # Step 4: Compute forward vector (toward center of plane)
642
    center = np.mean(rect, axis=0)
1✔
643
    forward = center - origin
1✔
644
    forward /= np.linalg.norm(forward)
1✔
645

646
    # Step 5: Compute right and re-orthogonalized up
647
    right = np.cross(up, forward)
1✔
648
    right /= np.linalg.norm(right)
1✔
649
    up = np.cross(forward, right)
1✔
650
    up /= np.linalg.norm(up)
1✔
651

652
    # Step 6: Assemble transform matrix
653
    transform = np.eye(4)
1✔
654
    transform[:3, 0] = right
1✔
655
    transform[:3, 1] = up
1✔
656
    transform[:3, 2] = forward
1✔
657
    transform[:3, 3] = origin
1✔
658

659
    return transform
1✔
660

661
def create_xyz_grid(bounds:List[float], resolutions:List[float])->np.ndarray:
1✔
662
    """Generate a xyz grid. If only a single value is needed, set the boundaries equal e.g. xMin=xMax and the resolution to 1 e.g. dx=1.\n
663

664
    Args:
665
        1.bounds (List[float]): [xMin,xMax,yMin,yMax,zMin,zMax]\n
666
        2.resolutions (List[float]):[dx,dy,dz]\n
667

668
    Returns:
669
        np.array(x,y,z)
670
    """
671
    assert(len(bounds) == 2*len(resolutions))
×
672

673
    values=[]
×
674
    for i,res in enumerate(resolutions):
×
675
        #fix single values
676
        if bounds[2*i]==bounds[2*i+1]:
×
677
            bounds[2*i+1]+=1
×
678
            res+=1
×
679
        #generate values       
680
        values.append( np.arange(bounds[2*i], bounds[2*i+1], res )) 
×
681
    grid  = np.meshgrid(values[0],values[1],values[2])
×
682
    return np.stack(grid)
×
683
def crop_dataframe_from_meshes(df: pd.DataFrame,meshes:List[o3d.geometry.TriangleMesh]) -> List[pd.DataFrame]:
1✔
684
    """Crop point cloud and divide the inliers per waterthight mesh. \n
685

686
    Args:
687
        1. dataFrame (pd.DataFrame): Pandas dataframe with first three columns as [X,Y,Z].\n
688
        2. meshes (o3d.geometry.TriangleMesh): meshes to test the inliers.\n
689

690
    Returns:
691
        List[o3d.geometry.PointCloud] 
692
    """
693
    meshes=ut.item_to_list(meshes)
×
694
    assert df.shape[0] >0
×
695
    assert 'TriangleMesh' in str(type(meshes[0]))    
×
696

697
    pcd=dataframe_to_pcd(df,pointFields=['x', 'y', 'z'])
×
698
    _,indices=crop_point_cloud_from_meshes(pcd,meshes)
×
699
    newDataFrames=[None]*len(meshes)
×
700
    for i,list in enumerate(indices):
×
701
        if len(list) != 0:
×
702
            newDataFrames[i]=df.loc[list]
×
703
    return newDataFrames
×
704
def crop_geometry_by_box(geometry:o3d.geometry, box:o3d.geometry.OrientedBoundingBox, subdivide:int = 0) ->o3d.geometry:
1✔
705
    """Crop portion of a mesh/pointcloud that lies within an OrientedBoundingBox.\n
706

707
    .. image:: ../../../docs/pics/selection_BB_mesh2.PNG
708

709
    Args:
710
        1. geometry (o3d.geometry.TriangleMesh or o3d.geometry.PointCloud): Geometry to be cropped\n
711
        2. box (o3d.geometry.OrientedBoundingBox): bouding cutter geometry\n
712
        3. subdivide (int): number of interations to increase the density of the mesh (1=x4, 2=x16, etc.)\n
713
        
714
    Returns:
715
        o3d.geometry.TriangleMesh or None
716
    """
717
    # transform box to axis aligned box 
718
    r=box.R
1✔
719
    t=box.center
1✔
720
    transformedbox=copy.deepcopy(box)
1✔
721
    transformedbox=transformedbox.translate(-t)
1✔
722
    transformedbox=transformedbox.rotate(r.transpose(),center=(0, 0, 0))
1✔
723
    
724
    # transform geometry to coordinate system of the box
725
    transformedGeometry=copy.deepcopy(geometry)
1✔
726
    transformedGeometry=transformedGeometry.translate(-t)
1✔
727
    transformedGeometry=transformedGeometry.rotate(r.transpose(),center=(0, 0, 0))
1✔
728

729
    # convert to pcd if geometry is a mesh (crop fails with mesh geometry)
730
    if type(geometry) is o3d.geometry.PointCloud:
1✔
731
        croppedGeometry=transformedGeometry.crop(transformedbox)             
1✔
732
    elif type(geometry) is o3d.geometry.TriangleMesh:
1✔
733
        if subdivide!=0:
1✔
734
            transformedGeometry=transformedGeometry.subdivide_midpoint(subdivide)
×
735
        indices=transformedbox.get_point_indices_within_bounding_box(transformedGeometry.vertices) # this is empty
1✔
736
        if len(indices) !=0:
1✔
737
            croppedGeometry=transformedGeometry.select_by_index(indices,cleanup=True)
1✔
738
        else:
739
            return None
×
740

741
    # return croppedGeometry to original position
742
    if croppedGeometry is not None:
1✔
743
        croppedGeometry=croppedGeometry.rotate(r,center=(0, 0, 0))
1✔
744
        croppedGeometry=croppedGeometry.translate(t)
1✔
745
        return croppedGeometry
1✔
746
    else:
747
        return None
×
748

749
def crop_geometry_by_distance(source: o3d.geometry.Geometry, reference:List[o3d.geometry.Geometry], threshold : float =0.1) -> o3d.geometry.PointCloud:
1✔
750
    """Returns the portion of a pointcloud that lies within a range of another mesh/point cloud.\n
751
    
752
    .. image:: ../../../docs/pics/crop_by_distance2.PNG
753

754
    Args:
755
        1. source (o3d.geometry.TriangleMesh or o3d.geometry.PointCloud) : point cloud to filter \n
756
        2. cutters (o3d.geometry.TriangleMesh or o3d.geometry.PointCloud): list of reference data \n
757
        3. threshold (float, optional): threshold Euclidean distance for the filtering.Defaults to 0.1m. \n
758

759
    Returns:
760
        o3d.geometry (TriangleMesh or PointCloud)
761
    """
762
    #validate inputs
763
    reference=join_geometries(ut.item_to_list(reference))
1✔
764
    assert threshold>0 ,f'threshold>0 expected, got: {threshold}'
1✔
765

766
    #sample reference if a mesh
767
    reference=reference.sample_points_uniformly(number_of_points=1000000) if 'TriangleMesh' in str(type(reference)) else reference
1✔
768
    #sample source if a mesh
769
    if type(source) is o3d.geometry.PointCloud:
1✔
770
        sourcePcd=source    
1✔
771
        #compute distance
772
        distances=sourcePcd.compute_point_cloud_distance(reference)
1✔
773
        #remove vertices > threshold
774
        ind=np.where(np.asarray(distances) <= threshold)[0]
1✔
775
        selectedPcd=source.select_by_index(ind) if ind.size >0 else None
1✔
776
    else:
777
        sourcePcd=o3d.geometry.PointCloud()
1✔
778
        sourcePcd.points=o3d.utility.Vector3dVector(np.asarray(source.vertices))
1✔
779
        #compute distance
780
        distances=sourcePcd.compute_point_cloud_distance(reference)
1✔
781
        #remove vertices > threshold
782
        ind=np.where(np.asarray(distances) <= threshold)[0]    
1✔
783
        selectedPcd=source.select_by_index(ind, cleanup=True)  if ind.size >0 else None
1✔
784
    return selectedPcd
1✔
785

786
def crop_geometry_by_raycasting(source:o3d.geometry.TriangleMesh, cutter: o3d.geometry.TriangleMesh, inside : bool = True,strict : bool = True ) -> o3d.geometry.TriangleMesh:
1✔
787
    """Select portion of a mesh that lies within a mesh shape (if not closed, convexhull is called).\n
788
    
789
    inside=True
790
    .. image:: ../../../docs/pics/crop_by_ray_casting1.PNG
791

792
    inside=False 
793
    .. image:: ../../../docs/pics/crop_by_ray_casting2.PNG
794

795
    Args:
796
        1. source (o3d.geometry.TriangleMesh) : mesh to cut \n
797
        2. cutter (o3d.geometry.TriangleMesh) : mesh that cuts \n
798
        3. inside (bool): 'True' to retain inside. 'False' to retain outside \n
799
        4. strict (bool): 'True' if no face vertex is allowed outside the bounds, 'False' allows 1 vertex to lie outside \n
800

801
    Returns:
802
        outputmesh(o3d.geometry.TriangleMesh)
803
    """
804
    #check if cutter is closed 
805
    cutter,_=cutter.compute_convex_hull() if not cutter.is_watertight() else (cutter,None)
×
806

807
    #raycast the scene to determine which points are inside
808
    query_points=o3d.core.Tensor(np.asarray(source.vertices),dtype=o3d.core.Dtype.Float32 )
×
809
    cpuMesh = o3d.t.geometry.TriangleMesh.from_legacy(cutter)
×
810
    scene = o3d.t.geometry.RaycastingScene()
×
811
    scene.add_triangles(cpuMesh)
×
812
    ans=scene.compute_occupancy(query_points)
×
813
    
814
    #create a mask of the results and process the mesh
815
    occupancyList = (ans==0) if inside else (ans>0)  
×
816
    outputmesh=copy.deepcopy(source)
×
817
    if strict:
×
818
        outputmesh.remove_vertices_by_mask(occupancyList)
×
819
        outputmesh.remove_degenerate_triangles()
×
820
        outputmesh.remove_unreferenced_vertices
×
821
    else:
822
        triangles=copy.deepcopy(np.asarray(outputmesh.triangles)) #can we remove this one?
×
823
        indices= [i for i, x in enumerate(occupancyList) if x == True]
×
824
        #mark all unwanted points as -1
825
        triangles[~np.isin(triangles,indices)] = -1
×
826
        # if 2/3 vertices are outside, flag the face
827
        occupancyList=np.ones ((triangles.shape[0],1), dtype=bool)
×
828

829
        for idx,row in enumerate(triangles):
×
830
            if (row[0] ==-1 and row[1]==-1) or (row[0] ==-1 and row[2]==-1) or (row[1] ==-1 and row[2]==-1):
×
831
                occupancyList[idx]=False
×
832
        outputmesh.remove_triangles_by_mask(occupancyList)
×
833
        outputmesh.remove_unreferenced_vertices()
×
834
    return outputmesh
×
835
def crop_mesh_by_convex_hull(source:trimesh.Trimesh, cutters: List[trimesh.Trimesh], inside : bool = True ) -> trimesh.Trimesh:
1✔
836
    """Cut a portion of a mesh that lies within the convex hull of another mesh.
837
    
838
    .. image:: ../../../docs/pics/crop_by_convex_hull.PNG
839

840
    Args:
841
        1. source (trimesh.Trimesh):   mesh that will be cut \n
842
        2. cutter (trimesh.Trimesh):   mesh of which the faces are used for the cuts. Face normals should point outwards (positive side) \n
843
        3. strict (bool):           True if source faces can only be part of a single submesh\n
844
        4. inside (bool):           True if retain the inside of the intersection\n
845
        
846
    Returns:
847
        mesh (trimesh.Trimesh) or None 
848
    """
849
    #validate list
850
    cutters=ut.item_to_list(cutters)
1✔
851

852
    submeshes=[]
1✔
853
    for cutter in cutters:
1✔
854
        submesh=None
1✔
855
        #compute faces and centers
856
        convexhull=cutter.convex_hull
1✔
857
        plane_normals=convexhull.face_normals
1✔
858
        plane_origins=convexhull.triangles_center
1✔
859

860
        if inside: # retain inside
1✔
861
            submesh=source.slice_plane(plane_origins, -1*plane_normals)
1✔
862
            if len(submesh.vertices)!=0:
1✔
863
                submeshes.append(submesh)
1✔
864
        else:# retain outside
865
            #cut source mesh for every slicing plane on the box
866
            meshes=[]
×
867
            for n, o in zip(plane_normals, plane_origins):
×
868
                tempMesh= source.slice_plane(o, n)
×
869
                if not tempMesh.is_empty:
×
870
                    meshes.append(tempMesh)
×
871
            if len(meshes) !=0: # gather pieces
×
872
                combined = trimesh.util.concatenate( [ meshes ] )
×
873
                combined.merge_vertices(merge_tex =True,merge_norm =True )
×
874
                combined.remove_duplicate_faces()
×
875
                submesh=combined
×
876
                submeshes.append(submesh)
×
877
    return submeshes
1✔
878

879
def crop_point_cloud_from_meshes(pcd: o3d.geometry.PointCloud,meshes:List[o3d.geometry.TriangleMesh]) -> List[o3d.geometry.PointCloud]:
1✔
880
    """Crop point cloud and divide the inliers per waterthight mesh. \n
881

882
    Args:
883
        1. pcd (o3d.geometry.PointCloud): point cloud to be cropped.\n
884
        2. meshes (o3d.geometry.TriangleMesh). cutter objects.\n 
885

886
    Returns:
887
        List[o3d.geometry.PointCloud] 
888
    """
889
    assert len(pcd.points) !=0
×
890
    assert 'TriangleMesh' in str(type(meshes[0]))
×
891

892
    meshes=ut.item_to_list(meshes)
×
893
    croppedPcds=[None]*len(meshes)
×
894
    indices=[None]*len(meshes)
×
895
    pcdRemaining=pcd
×
896
    for i,m in enumerate(meshes):
×
897
        #check if cutter is closed         
898
        m,_=m.compute_convex_hull() if not m.is_watertight() else (m,None)
×
899

900
        #create raycasting scene
901
        scene = o3d.t.geometry.RaycastingScene()
×
902
        cpuReference = o3d.t.geometry.TriangleMesh.from_legacy(m)
×
903
        _ = scene.add_triangles(cpuReference)
×
904

905
        # determine occupancy 
906
        query_points = o3d.core.Tensor(np.asarray(pcdRemaining.points), dtype=o3d.core.Dtype.Float32)
×
907
        occupancy = scene.compute_occupancy(query_points)
×
908
        indices[i]=np.where(occupancy.numpy() ==1 )[0]  
×
909
        nonIndices=np.where(occupancy.numpy() ==0 )[0] 
×
910

911
        # crop point cloud
912
        if len(indices[i]) !=0:
×
913
            croppedPcds[i]= pcd.select_by_index(indices[i])
×
914
            pcdRemaining=pcd.select_by_index(nonIndices) 
×
915
    return croppedPcds, indices
×
916

917
def dataframe_to_las(dataframe: pd.DataFrame,xyz:List[int]=[0,1,2],rgb:List[int]=None,dtypes:List[str]=None) -> laspy.lasdata:
1✔
918
    """Convert a dataframe representing a point cloud to a las point cloud file.
919
    View laspy dimension and type formatting at https://laspy.readthedocs.io/en/latest/lessbasic.html.\n
920
    
921
    E.g.: las=dataframe_to_las(dataframe,rgb=[3,4,5])    
922

923
    Args:
924
        1.dataframe (pd.DataFrame): data frame with a number of columns such as xyz, rgb and some scalar fields (conform numpy)
925
        2.xyz (List[int], optional): Indices of the xyz coordinates in the dataframe. Defaults to [0,1,2].
926
        3.rgb (List[int], optional): Indices of the color information in the dataframe e.g. [3,4,5]. Defaults to None.
927
        4.dtypes (List[str], optional): types of the scalar fields that will be added e.g. ['float32','uint8']. Defaults to [float32] equal to the length of the scalar fields.
928

929
    Returns:
930
        laspy.lasdata: output las file 
931
    """
932
    #0.get xyz data
933
    xyz=dataframe.iloc[:,:3].to_numpy()
×
934
    # 1. Create a new header
935
    header = laspy.LasHeader(point_format=3, version="1.2")
×
936
    header.offsets = np.min(xyz, axis=0)
×
937
    header.scales = np.array([0.1, 0.1, 0.1])
×
938

939
    # 2. Create a Las from xyz
940
    las = laspy.LasData(header)
×
941

942
    las.x = xyz[:, 0]
×
943
    las.y = xyz[:, 1]
×
944
    las.z = xyz[:, 2]
×
945
    
946
    # 3. add rgb if present
947
    if rgb:
×
948
        rgb=dataframe.iloc[:,rgb].to_numpy()
×
949
        las.red=rgb[:, 0]
×
950
        las.green=rgb[:, 1]
×
951
        las.blue=rgb[:, 2]
×
952
        
953
    # 4. Create extra dims for scalar fields
954
    names=dataframe.columns[3:] if rgb is None else dataframe.columns[[t for t in np.arange(3,len(dataframe.columns)) if t not in rgb]] #! there might be a problem here
×
955
    dtypes=dtypes if dtypes else ['float32' for n in names]
×
956
    extraBytesParams=[laspy.ExtraBytesParams(name=name, type=dtype) for name,dtype in zip(names,dtypes)]
×
957
    las.add_extra_dims(extraBytesParams)   
×
958
    [setattr(las,name,dataframe[name].to_numpy()) for i,name in enumerate(names)]
×
959
    
960
    return las
×
961

962
def dataframe_to_pcd(df:pd.DataFrame,xyz=[0,1,2],rgb=[3,4,5],n=None,transform:np.array=None)->o3d.geometry.PointCloud:
1✔
963
    """Convert Pandas dataframe to o3d.geometry.PointCloud.\n
964

965
    **NOTE**: this is slow. Ignoring color and normals speeds up the process by about 30%. More efficient method needed.\n
966

967
    Args:
968
        1. df (pd.DataFrame): Dataframe with named columns ['x', 'y', 'z'] and optional ['R', 'G', 'B'] and ['Nx', 'Ny', 'Nz'].\n
969
        2. pointFields (List[str]): optional column names. defaults to ['x', 'y', 'z','R', 'G', 'B','Nx', 'Ny', 'Nz']\n
970

971
    Raises:
972
        ValueError: No valid xyz data. Make sure column headers are names X,Y,Z.
973

974
    Returns:
975
        o3d.geometry.PointCloud 
976
    """
977
    #validate transform
978
    if transform is not None:
×
979
        assert transform.shape[0]==4
×
980
        assert transform.shape[1]==4
×
981

982
    # #validate pointfields    
983
    # if pointFields == None:
984
    #     pointFields=['x', 'y', 'z','R', 'G', 'B','Nx', 'Ny', 'Nz']
985
    # fields=[s.casefold() for s in pointFields]
986

987
    #create point cloud
988
    pcd=o3d.geometry.PointCloud()
×
989
    # if (all(elem.casefold() in fields for elem in ['X', 'Y', 'Z'])):
990
        # xyz=df.get([pointFields[0], pointFields[1], pointFields[2]])
991
    points=df.iloc[:,xyz].to_numpy()
×
992
    points=transform_points( points,transform) if transform is not None else points
×
993
    pcd.points=o3d.utility.Vector3dVector(points)
×
994
    # else:
995
    #     raise ValueError('No valid xyz data.')
996

997
    # if (all(elem.casefold() in fields for elem in ['R', 'G', 'B'])): 
998
        # rgb=df.get(['R', 'G', 'B'])
999
    colors=df.iloc[:,rgb].to_numpy()        
×
1000
    colors=colors/255    if np.amax(colors)>1 else colors
×
1001
    pcd.colors=o3d.utility.Vector3dVector(colors)
×
1002

1003
    # if (all(elem.casefold() in pointFields for elem in ['Nx', 'Ny', 'Nz'])): 
1004
    # nxyz=df.get(['Nx', 'Ny', 'Nz'])
1005
    if n:
×
1006
        normals=df.iloc[:,n].to_numpy()
×
1007
        normals=transform_points( normals,transform) if transform is not None else normals
×
1008
        pcd.normals=o3d.utility.Vector3dVector(normals)
×
1009

1010
        
1011
    # newnxyz=transform_points( nxyz.to_numpy(),transform) if transform is not None else nxyz.to_numpy()
1012
    # pcd.normals=o3d.utility.Vector3dVector(newnxyz)
1013
    
1014
    return pcd
×
1015

1016
def describe_element(name:str, df):
1✔
1017
    """ Takes the columns of a dataframe and builds a ply-like description.\n
1018
    
1019
    Args:
1020
        1. name: str\n
1021
        2. df: pandas DataFrame\n
1022
        
1023
    Returns:
1024
        element: list[str]
1025
    """
1026
    property_formats = {'f': 'float', 'u': 'uchar', 'i': 'int', 'b': 'bool'}
×
1027
    element = ['element ' + name + ' ' + str(len(df))]
×
1028
    if name == 'face':
×
1029
        element.append("property list uchar int vertex_indices")
×
1030
    else:
1031
        for i in range(len(df.columns)):
×
1032
            # get first letter of dtype to infer format
1033
            f = property_formats[str(df.dtypes[i])[0]]
×
1034
            element.append('property ' + f + ' ' + df.columns.values[i])
×
1035
    return element
×
1036

1037
def divide_box_in_boxes(box: o3d.geometry.Geometry,size:List[float]=None, parts:List[int]=None)->Tuple[List[o3d.geometry.Geometry],List[str]]:
1✔
1038
    """Subdivide an open3d OrientedBoundingBox or AxisAlignedBoundingBox into a set of smaller boxes (either by size of number of parts).
1039
    
1040
    .. image:: ../../../docs/pics/subselection1.PNG
1041

1042
    Args:
1043
        box (o3d.geometry.OrientedBoundingBox or AxisAlignedBoundingBox): box to divide
1044
        size (list[float], optional): X, Y and Z size of the subdivided boxes in meter e.g. [10,10,5].
1045
        parts (list[int], optional): X, Y and Z number of parts to divide the box in e.g. [7,7,1].
1046

1047
    Returns:
1048
        List[o3d.geometry.AxisAlignedBoundingBox]: list of boxes
1049
    """
1050
    #parse inputs
1051
    if 'OrientedBoundingBox' in str(type(box)):
×
1052
        # get bounds
1053
        extent=box.extent()        
×
1054
    elif 'AxisAlignedBoundingBox' in str(type(box)):
×
1055
        extent=box.get_extent()
×
1056
        
1057
    # get bounds
1058
    minBound=box.get_min_bound()
×
1059
    maxBound=box.get_max_bound()   
×
1060
    center=box.get_center() 
×
1061
    
1062
    # get xyz ranges within the box
1063
    size=size if size is not None and parts is None else extent / np.array(parts)
×
1064
    # if size > extent, take centerpoint
1065
    xRange=np.arange(minBound[0]+size[0]/2,maxBound[0],size[0]) if size[0]<=extent[0] else np.array([center[0]])
×
1066
    yRange=np.arange(minBound[1]+size[1]/2,maxBound[1],size[1]) if size[1]<=extent[1] else np.array([center[1]])
×
1067
    zRange=np.arange(minBound[2]+size[2]/2,maxBound[2],size[2]) if size[2]<=extent[2] else np.array([center[2]])
×
1068

1069
    # create names
1070
    xNames=np.arange(0,len(xRange))
×
1071
    yNames=np.arange(0,len(yRange))
×
1072
    zNames=np.arange(0,len(zRange))
×
1073
    xn,yn,zn=np.meshgrid(xNames,yNames,zNames,indexing='xy')
×
1074
    names = np.stack((xn,yn,zn), axis = -1)
×
1075
    names=np.reshape(names,(-1,3))
×
1076

1077
    #create relative center points
1078
    xx,yy,zz=np.meshgrid(xRange,yRange,zRange,indexing='xy')
×
1079
    grid = np.stack((xx,yy,zz), axis = -1)
×
1080
    grid_list=np.reshape(grid,(-1,3))
×
1081
    #create box
1082
    small_box=expand_box(box,u=-extent[0]+size[0],v=-extent[1]+size[1],w=-extent[2]+size[2])
×
1083

1084
    boxes=[]
×
1085
    for p in grid_list:
×
1086
        box=copy.deepcopy(small_box)
×
1087
        box.translate(p,False)
×
1088
        boxes.append(box)
×
1089
    return boxes,names
×
1090

1091

1092
def divide_pcd_per_height(heights:List[float], pointCloud:o3d.geometry.PointCloud)->List[o3d.geometry.PointCloud]:
1✔
1093
    """Devides a point cloud based on a set of heights.\n
1094

1095
    Args:
1096
        1. heights (List[float]): heights along which to split the point cloud.\n
1097
        2. pointCloud (o3d.geometry.PointCloud): PointCloud to split.\n
1098

1099
    Returns:
1100
        List[o3d.geometry.PointCloud] is ascending order.
1101
    """
1102
    heights=ut.item_to_list(heights)
×
1103
    #sort based on z
1104
    indices = np.argsort(np.asarray(pointCloud.points)[:,3])
×
1105
    sortedz=np.sort(np.asarray(pointCloud.points)[:,3])
×
1106
    #get splitting indices
1107
    splittingIndices=[np.find_nearest(sortedz, value) for value in heights[1:-1]] 
×
1108
    #split arrays and fetch data
1109
    indexArrays=np.split(indices, splittingIndices)[0]
×
1110
    pcds=[pointCloud.select_by_index(r.toList()) for r in indexArrays if r.size !=0]
×
1111
    return pcds
×
1112

1113
def e57_to_arrays(e57Path:str,e57Index : int = 0,percentage:float=1.0,tasknr:int=0)->Tuple[np.array,np.array,np.array,np.array,int]:
1✔
1114
    """Convert a scan from a pye57.e57.E57 file to a tuple of 4 arrays.\n
1115

1116
    Features:
1117
        0. ['cartesianX', 'cartesianY', 'cartesianZ'] \n
1118
        1. ['colorRed', 'colorGreen', 'colorBlue'] \n
1119
        2. ['nor:normalX', 'nor:normalY', 'nor:normalZ']\n
1120
        3. cartesianTransform (np.array)
1121
        4. tasknr (int): int to retrieve order in multiprocessing
1122

1123
    Args:
1124
        1. e57 (pye57.e57.E57) \n
1125
        2. e57Index (int,optional): index of the scan. Typically found in e57.scan_count \n
1126
        3. tasknr (int): int to retrieve order in multiprocessing
1127

1128
    Returns:
1129
        Tuple[pointArray(np.array),colorArray(np.array),normalArray(np.array),cartesianTransform(np.array),tasknr(int)]
1130
    """
1131
    e57 = pye57.E57(str(e57Path))
1✔
1132
    e57_update_point_field(e57)
1✔
1133
    
1134
    #get transformation
1135
    header = e57.get_header(e57Index)    
1✔
1136
    cartesianTransform= e57_get_cartesian_transform(header)
1✔
1137
    
1138
    #get points    
1139
    rawData = e57.read_scan_raw(e57Index)    
1✔
1140
    if all(elem in header.point_fields  for elem in ['cartesianX', 'cartesianY', 'cartesianZ']):   
1✔
1141
        pointArray=e57_get_xyz_from_raw_data(rawData)
1✔
1142
    elif all(elem in header.point_fields  for elem in ['sphericalRange', 'sphericalAzimuth', 'sphericalElevation']):
×
1143
        pointArray=e57_get_xyz_from_spherical_raw_data(rawData)
×
1144

1145
    #downnsample
1146
    if percentage <1.0:
1✔
1147
        indices=np.random.random_integers(0,len(pointArray)-1,int(len(pointArray)*percentage))
×
1148
        pointArray=pointArray[indices]
×
1149

1150
    #get color or intensity
1151
    colorArray=None
1✔
1152
    if (all(elem in header.point_fields  for elem in ['colorRed', 'colorGreen', 'colorBlue'])
1✔
1153
        or 'intensity' in header.point_fields ): 
1154
        colorArray=e57_get_colors(rawData)
1✔
1155
        if percentage <1.0:
1✔
1156
            colorArray=colorArray[indices]
×
1157

1158
    #get normals
1159
    normalArray=None
1✔
1160
    if all(elem in header.point_fields  for elem in ['nor:normalX', 'nor:normalY', 'nor:normalZ']): 
1✔
1161
        normalArray=e57_get_normals(rawData)
×
1162
        if percentage <1.0:
×
1163
            normalArray=normalArray[indices]
×
1164

1165
    return pointArray,colorArray,normalArray,cartesianTransform,tasknr
1✔
1166

1167
def e57_to_pcd(e57:pye57.e57.E57 , e57Index : int = 0,percentage:float=1.0)->o3d.geometry.PointCloud:
1✔
1168
    """Convert a scan from a pye57.e57.E57 file to o3d.geometry.PointCloud.
1169

1170
    Args:
1171
        1. e57 (pye57.e57.E57) 
1172
        2. e57Index (int,optional) 
1173
        3. percentage (float,optional): downsampling ratio. defaults to 1.0 (100%) 
1174

1175
    Returns:
1176
        o3d.geometry.PointCloud
1177
    """
1178
    e57_update_point_field(e57)
1✔
1179
    header = e57.get_header(e57Index)
1✔
1180
    #get transformation
1181
    cartesianTransform=e57_get_cartesian_transform(header)
1✔
1182
    #get raw geometry (no transformation)
1183
    rawData = e57.read_scan_raw(e57Index)    
1✔
1184

1185
        
1186
    if all(elem in header.point_fields  for elem in ['cartesianX', 'cartesianY', 'cartesianZ']):   
1✔
1187
        pointArray=e57_get_xyz_from_raw_data(rawData)
1✔
1188
    elif all(elem in header.point_fields  for elem in ['sphericalRange', 'sphericalAzimuth', 'sphericalElevation']):   
×
1189
        pointArray=e57_get_xyz_from_spherical_raw_data(rawData)
×
1190
    else:
1191
        raise ValueError('e57 rawData parsing failed.')
×
1192

1193
    #downnsample
1194
    if percentage <1.0:
1✔
1195
        indices=np.random.randint(0,len(pointArray)-1,int(len(pointArray)*percentage))
1✔
1196
        pointArray=pointArray[indices]
1✔
1197

1198
    #create point cloud
1199
    points = o3d.utility.Vector3dVector(pointArray)
1✔
1200
    pcd=o3d.geometry.PointCloud(points)
1✔
1201

1202
    #get color or intensity
1203
    if (all(elem in header.point_fields  for elem in ['colorRed', 'colorGreen', 'colorBlue'])
1✔
1204
        or 'intensity' in header.point_fields ): 
1205
        colors=e57_get_colors(rawData)
1✔
1206
        if percentage <1.0:
1✔
1207
            colors=colors[indices]
1✔
1208
        pcd.colors=o3d.utility.Vector3dVector(colors) 
1✔
1209

1210
    #get normals
1211
    if all(elem in header.point_fields  for elem in ['nor:normalX', 'nor:normalY', 'nor:normalZ']): 
1✔
1212
        normals=e57_get_normals(rawData)
×
1213
        if percentage <1.0:
×
1214
            normals=normals[indices]
×
1215
        pcd.normals=o3d.utility.Vector3dVector(normals)
×
1216

1217
    #return transformed data
1218
    pcd.transform(cartesianTransform)    if cartesianTransform is not None else None
1✔
1219
    return pcd
1✔
1220
def get_e57_from_pcd (pcd:o3d.geometry.PointCloud ) ->dict:
1✔
1221
    """Returns the data of an o3d.geometry.PointCloud as the data structure of an e57 file so it can be written to file. 
1222

1223
    Args:
1224
        pcd (o3d.geometry.PointCloud)
1225

1226
    Returns:
1227
        Data3D dictionary conform the E57 standard.
1228
    """
1229
    data3D={}
×
1230
    if pcd.has_points():
×
1231
        array=np.asarray(pcd.points)
×
1232
        cartesianX=array[:,0]
×
1233
        cartesianY=array[:,1]
×
1234
        cartesianZ=array[:,2]
×
1235
        data3D.update({'cartesianX' : cartesianX,'cartesianY':cartesianY,'cartesianZ':cartesianZ})
×
1236

1237
    if pcd.has_colors():
×
1238
        array=np.asarray(pcd.colors)
×
1239
        colorRed=array[:,0]*255
×
1240
        colorGreen=array[:,1]*255
×
1241
        colorBlue=array[:,2]*255
×
1242
        data3D.update({'colorRed' : colorRed,'colorGreen':colorGreen,'colorBlue':colorBlue})
×
1243
    
1244
    if pcd.has_normals():
×
1245
        array=np.asarray(pcd.normals)
×
1246
        nx=array[:,0]
×
1247
        ny=array[:,1]
×
1248
        nz=array[:,2]
×
1249
        data3D.update({'nor:normalX' : nx,'nor:normalY':ny,'nor:normalZ':nz})
×
1250
    return data3D
×
1251

1252
def e57_array_to_pcd(tuple) -> o3d.geometry.PointCloud:
1✔
1253
    """Returns PointCloud from e57 arrays.\n
1254

1255
    Args:
1256
        tuple (Tuple): \n
1257
            1. pointArray:np.array \n
1258
            2. colorArray:np.array \n
1259
            3. normalArray:np.array \n
1260
            4. cartesianTransform:np.array \n
1261

1262
    Returns:
1263
        o3d.geometry.PointCloud
1264
    """        
1265
    pointcloud = o3d.geometry.PointCloud()
1✔
1266
    pointcloud.points = o3d.utility.Vector3dVector(tuple[0])
1✔
1267
    if tuple[1] is not None:
1✔
1268
        pointcloud.colors = o3d.utility.Vector3dVector(tuple[1])
1✔
1269
    if tuple[2] is not None:
1✔
1270
        pointcloud.normals = o3d.utility.Vector3dVector(tuple[2])
1✔
1271
    if len(tuple)==5 and 'ndarray' in str(type(tuple[3])) :
1✔
1272
        pointcloud.transform(tuple[3])  
1✔
1273
    return pointcloud
1✔
1274

1275
def e57_dict_to_pcd(e57:dict,percentage:float=1.0)->o3d.geometry.PointCloud:
1✔
1276
    """Convert a scan from a e57 dictionary (raw scandata) to o3d.geometry.PointCloud.
1277

1278
    Args:
1279
        1. e57 dict
1280
        2. e57Index (int,optional) 
1281
        3. percentage (float,optional): downsampling ratio. defaults to 1.0 (100%) 
1282

1283
    Returns:
1284
        o3d.geometry.PointCloud
1285
    """
1286
    if all(elem in e57.keys()  for elem in ['cartesianX', 'cartesianY', 'cartesianZ']):   
1✔
1287
            pointArray=e57_get_xyz_from_raw_data(e57)
1✔
1288
    elif all(elem in e57.keys()  for elem in ['sphericalRange', 'sphericalAzimuth', 'sphericalElevation']):   
×
1289
        pointArray=e57_get_xyz_from_spherical_raw_data(e57)
×
1290
    else:
1291
        raise ValueError('e57 rawData parsing failed.')
×
1292

1293
    #downnsample
1294
    if percentage <1.0:
1✔
1295
        indices=np.random.randint(0,len(pointArray)-1,int(len(pointArray)*percentage))
×
1296
        pointArray=pointArray[indices]
×
1297

1298
    #create point cloud
1299
    points = o3d.utility.Vector3dVector(pointArray)
1✔
1300
    pcd=o3d.geometry.PointCloud(points)
1✔
1301

1302
    #get color or intensity
1303
    if (all(elem in e57.keys()  for elem in ['colorRed', 'colorGreen', 'colorBlue'])
1✔
1304
        or 'intensity' in e57.keys() ): 
1305
        colors=e57_get_colors(e57)
1✔
1306
        if percentage <1.0:
1✔
1307
            colors=colors[indices]
×
1308
        pcd.colors=o3d.utility.Vector3dVector(colors) 
1✔
1309

1310
    #get normals
1311
    if all(elem in e57.keys()  for elem in ['nor:normalX', 'nor:normalY', 'nor:normalZ']): 
1✔
1312
        normals=e57_get_normals(e57)
×
1313
        if percentage <1.0:
×
1314
            normals=normals[indices]
×
1315
        pcd.normals=o3d.utility.Vector3dVector(normals)
×
1316

1317
    #return transformed data
1318
    return pcd
1✔
1319

1320
def e57_fix_rotation_order(rotation_matrix:np.array) -> np.array:
1✔
1321
    """Switch the rotation from clockwise to counter-clockwise in e57 rotation matrix. See following url for more information:\n
1322

1323
    https://robotics.stackexchange.com/questions/10702/rotation-matrix-sign-convention-confusion\n
1324
    
1325
    Currently only changes the signs of elements [0,1,5,8].\n
1326

1327
    Args:
1328
        rotation_matrix(np.array(3x3))
1329
    
1330
    Returns:
1331
        transformed rotation_matrix(np.array(3x3))
1332
    """
1333
    r=rotation_matrix
×
1334
    return np.array([[-r[0,0],-r[0,1],r[0,2]],
×
1335
                    [r[1,0],r[1,1],-r[1,2]],
1336
                    [r[2,0],r[2,1],-r[2,2]]])
1337

1338
def e57_get_cartesian_transform(header:dict)->np.ndarray:
1✔
1339
    """Returns cartesianTransform (np.array(4x4)) from a e57 header.\n
1340

1341
    Args:
1342
        header (dict): retrieved from pye57 e57.get_header(e57Index)
1343

1344
    Returns:
1345
        cartesianTransform (np.array(4x4))
1346
    """
1347
    cartesianTransform=np.diag(np.diag(np.ones((4,4))))    
×
1348
    if 'pose' in header.scan_fields:
×
1349
        rotation_matrix=None
×
1350
        translation=None
×
1351
        if getattr(header,'rotation',None) is not None:
×
1352
                rotation_matrix=header.rotation_matrix
×
1353
        if getattr(header,'translation',None) is not None:
×
1354
            translation=header.translation
×
1355
        cartesianTransform=get_cartesian_transform(rotation=rotation_matrix,translation=translation)
×
1356
    return  cartesianTransform
×
1357

1358
def e57_get_cartesian_transform(header)-> np.array:
1✔
1359
    """Returns the cartesianTransform from an e57 header.\n
1360

1361
    Args:
1362
        header (e57): rotation and translation should be present.
1363

1364
    Returns:
1365
        np.array (4x4): transformation Matrix
1366
    """
1367
    if 'pose' in header.scan_fields:
1✔
1368
        rotation_matrix=None
1✔
1369
        translation=None
1✔
1370
        if getattr(header,'rotation',None) is not None:
1✔
1371
            rotation_matrix=header.rotation_matrix
1✔
1372
        if getattr(header,'translation',None) is not None:
1✔
1373
            translation=header.translation
1✔
1374
        cartesianTransform=get_cartesian_transform(rotation=rotation_matrix,translation=translation)
1✔
1375
        return cartesianTransform
1✔
1376
    else:
1377
        return None
1✔
1378

1379
def e57_get_colors(rawData: dict)->np.ndarray:
1✔
1380
    """Extract color of intensity information from e57 raw data (3D data) and output Open3D o3d.utility.Vector3dVector(colors).\n
1381

1382
    Args:
1383
        rawData(dict): e57 dictionary resulting from e57.read_scan_raw(e57Index)\n
1384
    
1385
    Returns:
1386
        np.array(nx3): RGB or intensity color information. RGB is prioritised.
1387
    """ 
1388
    r,g,b,i=None,None,None,None
1✔
1389
    for key in rawData.keys():
1✔
1390
        if all(elem.casefold() in key.casefold() for elem in ['red']):
1✔
1391
            r=rawData.get(key)
1✔
1392
        if all(elem.casefold() in key.casefold() for elem in ['green']):
1✔
1393
            g=rawData.get(key)
1✔
1394
        if all(elem.casefold() in key.casefold() for elem in ['blue']):
1✔
1395
            b=rawData.get(key)  
1✔
1396
        if all(elem.casefold() in key.casefold() for elem in ['intensity']):
1✔
1397
            i=rawData.get(key)  
1✔
1398

1399
    if all(n is not None for n in [r ,g, b]):
1✔
1400
        if np.amax(r)<=1:
1✔
1401
            colors=np.c_[r , g,b ]  
×
1402
        elif np.amax(r) <=255:
1✔
1403
            colors=np.c_[r/255 , g/255,b/255 ]  
1✔
1404
        else:
1405
            r=(r - np.min(r))/np.ptp(r)
×
1406
            g=(g - np.min(g))/np.ptp(g)
×
1407
            b=(b - np.min(b))/np.ptp(b)
×
1408
            colors=np.c_[r , g,b ]  
×
1409
        return np.reshape(colors.flatten('F'),(len(r),3))
1✔
1410

1411
    elif i is not None:
×
1412
        if np.amax(i) <=1:
×
1413
            colors=np.c_[i , i,i ]  
×
1414
        else:
1415
            i=(i - np.min(i))/np.ptp(i)
×
1416
            colors=np.c_[i , i,i ]  
×
1417
        return np.reshape(colors.flatten('F'),(len(i),3))
×
1418

1419
def e57_get_normals(rawData:dict)->np.ndarray:
1✔
1420
    """Returns normal vectors from e57 rawData.\n
1421

1422
    Args:
1423
        rawData (dict): e57 dictionary resulting from e57.read_scan_raw(e57Index).\n
1424

1425
    Returns:
1426
         np.array(nx3): magnitude 1
1427
    """
1428
    nx,ny,nz = None,None,None
×
1429
    for key in rawData.keys():
×
1430
        if all(elem.casefold() in key.casefold() for elem in ['n','o' ,'x']):
×
1431
            nx=rawData.get(key)
×
1432
        if all(elem.casefold() in key.casefold() for elem in ['n', 'o' ,'y']):
×
1433
            ny=rawData.get(key)
×
1434
        if all(elem.casefold() in key.casefold() for elem in ['n','o' , 'z']):
×
1435
            nz=rawData.get(key)
×
1436
    return np.reshape(np.vstack(( nx,ny,nz)).flatten('F'),(len(nx),3)) if all(n is not None for n in [nx ,ny, nz]) else None
×
1437
    
1438

1439
def e57_get_xyz_from_raw_data(rawData: dict)->np.ndarray:
1✔
1440
    """Returns the xyz coordinates from e57 raw data.\n
1441

1442
    Args:
1443
        rawData (e57 dict):  rawData = e57.read_scan_raw(e57Index).   
1444
        
1445
    Returns:
1446
        np.array (nx3): XYZ cartesian coordinates np.array.
1447
    """
1448
    x=rawData.get('cartesianX')
1✔
1449
    y=rawData.get('cartesianY')
1✔
1450
    z=rawData.get('cartesianZ') 
1✔
1451
    pointArray=np.reshape(np.vstack(( x,y,z)).flatten('F'),(len(x),3))
1✔
1452
    return pointArray
1✔
1453

1454
def e57_get_xyz_from_spherical_raw_data(rawData: dict) -> np.array:
1✔
1455
    """Converts spherical(rae) to cartesian(xyz), where rae = range, azimuth(theta), 
1456
    elevation(phi). Where range is in meters and angles are in radians.\n
1457
    
1458
    Reference for formula: http://www.libe57.org/bestCoordinates.html \n
1459

1460
    Args:
1461
        rawData (e57 dict):  rawData = e57.read_scan_raw(e57Index).  
1462
        
1463
    Returns:
1464
        np.array (nx3): XYZ cartesian coordinates np.array.
1465
    """
1466
    range = rawData.get('sphericalRange')
×
1467
    theta = rawData.get('sphericalAzimuth')
×
1468
    phi =  rawData.get('sphericalElevation')
×
1469
    range_cos_phi = range * np.cos(phi)
×
1470
    pointArray=np.reshape(np.vstack( range_cos_phi * np.cos(theta),
×
1471
                                    range_cos_phi * np.sin(theta),
1472
                                    range * np.sin(phi)).flatten('F'),(len(range),3))
1473
    return pointArray
×
1474

1475
def e57_update_point_field(e57:pye57.e57.E57):
1✔
1476
    """Update e57 point fields with any point field in the file.
1477

1478
    Args:
1479
        e57 (pye57.e57.E57):
1480
    """
1481
    header = e57.get_header(0)
1✔
1482
    pointFields=header.point_fields
1✔
1483
    for f in pointFields:
1✔
1484
        pye57.e57.SUPPORTED_POINT_FIELDS.update({f : 'd'})
1✔
1485

1486
def e57path_to_pcd(e57Path:Path|str , e57Index : int = 0,percentage:float=1.0) ->o3d.geometry.PointCloud:
1✔
1487
    """Load an e57 file and convert the data to o3d.geometry.PointCloud.\n
1488

1489
    Args:
1490
        e57path
1491

1492
    Raises:
1493
        ValueError: Invalid e57Path.
1494

1495
    Returns:
1496
        o3d.geometry.PointCloud
1497
    """
1498
    e57 = pye57.E57(str(e57Path))
1✔
1499
    e57_update_point_field(e57)
1✔
1500
    pcd=e57_to_pcd(e57,e57Index,percentage)
1✔
1501
    return pcd
1✔
1502

1503
def e57path_to_pcds_multiprocessing(e57Path:str,percentage:float=1.0) ->List[o3d.geometry.PointCloud]:
1✔
1504
    """Load an e57 file and convert all data to a list of o3d.geometry.PointCloud objects.\n
1505

1506
    **NOTE**: Complex types cannot be pickled (serialized) by Windows. Therefore, a two step parsing is used where e57 data is first loaded as np.arrays with multi-processing.
1507
    Next, the arrays are passed to o3d.geometry.PointClouds outside of the loop.\n  
1508

1509
    **NOTE**: starting parallel processing takes a bit of time. This method will start to outperform single-core import from 3+ pointclouds.\n
1510

1511
    Args:
1512
        1. e57path(str): absolute path to .e57 file\n
1513
        2. percentage(float,optional): percentage of points to load. Defaults to 1.0 (100%)\n
1514

1515
    Raises:
1516
        ValueError: Invalid e57Path.
1517

1518
    Returns:
1519
        o3d.geometry.PointCloud
1520
    """   
1521
    #update pointfields
1522
    e57 = pye57.E57(str(e57Path))
1✔
1523
    e57_update_point_field(e57)
1✔
1524
    pcds=[None]*e57.scan_count
1✔
1525
    #set up multi-processing
1526
    with concurrent.futures.ProcessPoolExecutor() as executor:
1✔
1527
        # First convert all e57 data to np.arrays
1528
        results=[executor.submit(e57_to_arrays,e57Path=e57Path,e57Index=s,percentage=percentage,tasknr=s) for s in range(e57.scan_count)]
1✔
1529
        # Next, the arrays are assigned to point clouds outside the loop.
1530
        for r in concurrent.futures.as_completed(results):
1✔
1531
            tasknr=r.result()[-1]
1✔
1532
            print(r.result()[-2])
1✔
1533
            pcd=e57_array_to_pcd(r.result())
1✔
1534
            pcds[tasknr]=pcd
1✔
1535
    return pcds
1✔
1536

1537
def expand_box(box: o3d.geometry, u=5.0,v=5.0,w=5.0) -> o3d.geometry:
1✔
1538
    """expand an o3d.geometry.BoundingBox in u(x), v(y) and w(z) direction with a certain offset.
1539

1540
    Args:
1541
        1. box (o3d.geometry.OrientedBoundingBox, o3d.geometry.AxisAlignedBoundingBox,o3d.geometry.TriangleMesh box)
1542
        2. u (float, optional): Offset in X. Defaults to 5.0m.
1543
        3. v (float, optional): Offset in Y. Defaults to 5.0m.
1544
        4. w (float, optional): Offset in Z. Defaults to 5.0m.
1545

1546
    Returns:
1547
        o3d.geometry.OrientedBoundingBox or o3d.geometry.AxisAlignedBoundingBox
1548
    """        
1549
    if isinstance(box,o3d.geometry.TriangleMesh) and len(np.asarray(box.vertices))==8:
1✔
1550
        vertices=np.array(box.vertices)
×
1551
        #compute modifications
1552
        xmin=-u/2
×
1553
        xmax=u/2
×
1554
        ymin=-v/2
×
1555
        ymax=v/2
×
1556
        zmin=-w/2
×
1557
        zmax=w/2
×
1558
        vertices=np.array([vertices[0,:]+[xmin,ymin,zmin],
×
1559
                            vertices[0,:]+[xmax,ymin,zmin],
1560
                            vertices[0,:]+[xmin,ymax,zmin],
1561
                            vertices[0,:]+[xmax,ymax,zmin],
1562
                            vertices[0,:]+[xmin,ymin,zmax],
1563
                            vertices[0,:]+[xmax,ymin,zmax],
1564
                            vertices[0,:]+[xmin,ymax,zmax],
1565
                            vertices[0,:]+[xmax,ymax,zmax]])
1566
        box.vertices = o3d.utility.Vector3dVector(vertices)
×
1567
        return box
×
1568
    elif isinstance(box,o3d.geometry.OrientedBoundingBox):
1✔
1569
        center = box.get_center()
1✔
1570
        orientation = box.R 
1✔
1571
        extent = box.extent + [u,v,w] 
1✔
1572
        return o3d.geometry.OrientedBoundingBox(center,orientation,extent) 
1✔
1573
    elif isinstance(box,o3d.geometry.AxisAlignedBoundingBox):
×
1574
        # assert ((abs(u)<= box.get_extent()[0]) and (abs(v)<=box.get_extent()[1]) and (abs(w)<=box.get_extent()[2])), f'cannot schrink more than the extent of the box.'
1575
        minBound=box.get_min_bound()
×
1576
        maxBound=box.get_max_bound()
×
1577
        new_minBound=minBound-np.array([u,v,w])/2
×
1578
        new_maxBound=maxBound+np.array([u,v,w])/2        
×
1579
        return o3d.geometry.AxisAlignedBoundingBox(new_minBound,new_maxBound) 
×
1580
    else:
1581
        raise ValueError('Invalid input type')
×
1582

1583
def extract_points(geometry):
1✔
1584
    """Extracts points from PointCloud, TriangleMesh, LineSet, Vector3dVector or NumPy array."""
1585
    if isinstance(geometry, np.ndarray):
1✔
1586
        if geometry.ndim == 1 and geometry.size == 6: # [xMin,xMax,yMin,yMax,zMin,zMax]
1✔
1587
            x_min, x_max, y_min, y_max, z_min, z_max = geometry
1✔
1588
            corners = np.array([
1✔
1589
                [x_min, y_min, z_min],
1590
                [x_min, y_min, z_max],
1591
                [x_min, y_max, z_min],
1592
                [x_min, y_max, z_max],
1593
                [x_max, y_min, z_min],
1594
                [x_max, y_min, z_max],
1595
                [x_max, y_max, z_min],
1596
                [x_max, y_max, z_max],
1597
            ])
1598
            return corners
1✔
1599
        elif geometry.ndim == 1 and geometry.size == 9: #  [center(3),extent(3),euler_angles(3)]
1✔
1600
            center=geometry[:3]
1✔
1601
            extent=geometry[3:6]
1✔
1602
            euler_angles=geometry[6:9]        
1✔
1603
            rotation_matrix = R.from_euler('xyz', euler_angles, degrees=True).as_matrix()
1✔
1604
            box = o3d.geometry.OrientedBoundingBox(center, rotation_matrix, extent)
1✔
1605
            return box
1✔
1606
        if geometry.ndim != 2 or geometry.shape[1] != 3:
1✔
1607
            raise ValueError("NumPy array must have shape (N, 3)")
×
1608
        return geometry
1✔
1609
    elif isinstance(geometry, (o3d.geometry.PointCloud,  o3d.geometry.LineSet)):
1✔
1610
        return np.asarray(geometry.points)
1✔
1611
    elif isinstance(geometry, o3d.geometry.TriangleMesh):
1✔
1612
        return np.asarray(geometry.vertices)
1✔
1613
    elif isinstance(geometry, o3d.geometry.OrientedBoundingBox):
1✔
1614
        return geometry
1✔
1615
    elif isinstance(geometry ,o3d.utility.Vector3dVector):
1✔
1616
        return np.asarray(geometry)
1✔
1617
    else:
1618
        raise TypeError("Unsupported geometry type. Use PointCloud, TriangleMesh, LineSet, Vector3dVector or NumPy array.")
×
1619

1620
def generate_virtual_images(geometries: List[o3d.geometry.Geometry],cartesianTransforms: List[np.array],width:int=640,height:int=480,f:float=400)-> List[o3d.geometry.Image]:
1✔
1621
    """Generate a set of Open3D Images from cartesianTransforms and geometries. \n
1622
    The same intrinsic camera parameters are used for all cartesianTransforms that are passed to the function.\n
1623

1624
    .. image:: ../../../docs/pics/rendering3.PNG
1625

1626
    Args:
1627
        1. geometries (List[o3d.geometry]):o3d.geometry.PointCloud or o3d.geometry.TriangleMesh \n
1628
        2. cartesianTransforms (List[np.array 4x4]): [Rt] \n
1629
        3. width (int, optional): image width in pix. Defaults to 640pix.\n
1630
        4. height (int, optional): image height in pix. Defaults to 480pix. \n
1631
        5. f (float, optional): focal length in pix. Defaults to 400pix. \n
1632

1633
    Returns:
1634
        List[o3d.geometry.Image]
1635
    """
1636
    #set renderer
1637
    render = o3d.visualization.rendering.OffscreenRenderer(width,height)
×
1638
    mtl=o3d.visualization.rendering.MaterialRecord()
×
1639
    mtl.base_color = [1.0, 1.0, 1.0, 1.0]  # RGBA
×
1640
    mtl.shader = "defaultUnlit"
×
1641

1642
    # set internal camera orientation
1643
    fx=f
×
1644
    fy=f
×
1645
    cx=width/2-0.5
×
1646
    cy=width/2-0.5
×
1647
    intrinsic=o3d.camera.PinholeCameraIntrinsic(width,height,fx,fy,cx,cy)
×
1648
    
1649
    #add geometries
1650
    geometries=ut.item_to_list(geometries)
×
1651
    for idx,geometry in enumerate(geometries):
×
1652
        render.scene.add_geometry(str(idx),geometry,mtl) 
×
1653

1654
    #set cameras and generate images
1655
    list=[]
×
1656
    cartesianTransforms=ut.item_to_list(cartesianTransforms)
×
1657
    for cartesianTransform in cartesianTransforms:
×
1658
        extrinsic=np.linalg.inv(cartesianTransform)
×
1659
        render.setup_camera(intrinsic,extrinsic)
×
1660
        img = render.render_to_image()
×
1661
        list.append(img)
×
1662
    return list if len(list) !=0 else None
×
1663

1664
def get_backface_center_transform(obb: o3d.geometry.OrientedBoundingBox) -> np.ndarray:
1✔
1665
    """
1666
    Returns the full 4x4 Cartesian transform (position and rotation) of the center
1667
    of the back face (along -Z in local space) of an oriented bounding box.
1668

1669
    Parameters:
1670
        obb (o3d.geometry.OrientedBoundingBox): The oriented bounding box.
1671

1672
    Returns:
1673
        np.ndarray: 4x4 transformation matrix for the center of the back face.
1674
    """
1675
    # Rotation (3x3)
1676
    R = obb.R
×
1677

1678
    # Vector pointing from the center toward the back face in local coordinates
1679
    back_offset_local = np.array([0, 0, -obb.extent[2] / 2])
×
1680

1681
    # Convert to world space using rotation
1682
    back_offset_world = R @ back_offset_local
×
1683

1684
    # New origin = center of the back face
1685
    backface_origin = obb.center + back_offset_world
×
1686

1687
    # Construct 4x4 transform
1688
    transform = np.eye(4)
×
1689
    transform[:3, :3] = R
×
1690
    transform[:3, 3] = backface_origin
×
1691

1692
    return transform
×
1693

1694
def get_box_inliers(sourceBox:o3d.geometry.OrientedBoundingBox, testBoxes: List[o3d.geometry.OrientedBoundingBox],t_d:float=0.5)->List[int]:
1✔
1695
    """Return the indices of the testBoxes of which the bounding points lie within the sourceBox.\n
1696

1697
    .. image:: ../../../docs/pics/get_box_inliers1.PNG
1698

1699
    Args:
1700
        1. sourceBox (o3d.geometry.OrientedBoundingBox) \n
1701
        2. testBoxes (o3d.geometry.OrientedBoundingBox)\n
1702
        
1703
    Returns:
1704
        list (List[int]): Indices of testBoxes \n
1705
    """
1706
    sourceBox=expand_box(sourceBox,u=t_d,v=t_d,w=t_d)
1✔
1707
    testBoxes=ut.item_to_list(testBoxes) 
1✔
1708
    array= [False] * len(testBoxes)
1✔
1709
    for idx,testbox in enumerate(testBoxes):
1✔
1710
        if testbox is not None:
1✔
1711
            points=testbox.get_box_points()
1✔
1712
            indices=sourceBox.get_point_indices_within_bounding_box(points)        
1✔
1713
            if len(indices) !=0:
1✔
1714
                array[idx]= True
1✔
1715
    list = [ i for i in range(len(array)) if array[i]]
1✔
1716
    return list
1✔
1717

1718
def get_box_intersections(sourceBox:o3d.geometry.OrientedBoundingBox, testBoxes: List[o3d.geometry.OrientedBoundingBox])->List[int]:
1✔
1719
    """Return indices of testBoxes of which the geometry intersects with the sourceBox.\n
1720
    2 oriented bounding boxes (A,B) overlap if the projection from B in the coordinate system of A on all the axes overlap. \n
1721
    The projection of B on the oriented axes of A is simply the coordinate range for that axis.\n
1722

1723
    .. image:: ../../../docs/pics/get_box_inliers1.PNG
1724

1725
    Args:
1726
        1. sourceBox (o3d.geometry.OrientedBoundingBox):   box to test\n
1727
        2. testBoxes (o3d.geometry.OrientedBoundingBox):   boxes to test\n
1728

1729
    Returns:
1730
        list (List[int]):       indices of testBoxes
1731
    """       
1732
    #validate inputs
1733
    testBoxes=ut.item_to_list(testBoxes)
1✔
1734
    array= [False] * len(testBoxes)
1✔
1735

1736
    for idx,testbox in enumerate(testBoxes):
1✔
1737
    # compute axes of box A
1738
        if testbox is not None:
1✔
1739
            #transform box to aligned coordinate system
1740
            transformedboxA=copy.deepcopy(sourceBox)
1✔
1741
            transformedboxA=transformedboxA.translate(-sourceBox.center)
1✔
1742
            transformedboxA=transformedboxA.rotate(sourceBox.R.transpose(),center=(0, 0, 0))
1✔
1743
            
1744
            #transform testbox to aligned coordinate system
1745
            transformedboxB=copy.deepcopy(testbox)
1✔
1746
            transformedboxB=transformedboxB.translate(-sourceBox.center)
1✔
1747
            transformedboxB=transformedboxB.rotate(sourceBox.R.transpose(),center=(0, 0, 0))
1✔
1748

1749
            # compute coordinates of bounding points of B in coordinate system of A
1750
            minA=transformedboxA.get_min_bound()
1✔
1751
            minB=transformedboxB.get_min_bound()
1✔
1752
            maxA=transformedboxA.get_max_bound()
1✔
1753
            maxB=transformedboxB.get_max_bound()
1✔
1754

1755
            if (maxB[0]>=minA[0] and minB[0]<=maxA[0]):
1✔
1756
                if (maxB[1]>=minA[1] and minB[1]<=maxA[1]):
1✔
1757
                    if (maxB[2]>=minA[2] and minB[2]<=maxA[2]):
1✔
1758
                        array[idx]= True  
1✔
1759
    # return index if B overlaps with A in all three axes u,v,w 
1760
    list = [ i for i in range(len(array)) if array[i]]
1✔
1761
    return list
1✔
1762

1763
def get_cartesian_bounds(geometry : o3d.geometry.Geometry) ->np.ndarray:
1✔
1764
    """Get cartesian bounds from Open3D geometry.\n
1765

1766
    Args:
1767
        geometry (o3d.geometry): Open3D geometry supertype (PointCloud, TriangleMesh, OrientedBoundingBox, etc.)\n
1768

1769
    Returns:
1770
        np.array: [xMin,xMax,yMin,yMax,zMin,zMax]
1771
    """
1772
    max=geometry.get_max_bound()
1✔
1773
    min=geometry.get_min_bound()
1✔
1774
    return np.array([min[0],max[0],min[1],max[1],min[2],max[2]])
1✔
1775

1776
def get_cartesian_transform(translation: np.array = None,
1✔
1777
                            rotation: np.array = None                            
1778
                            ) -> np.ndarray:
1779
    """Return cartesianTransform from rotation, translation or cartesianBounds inputs.
1780

1781
    Args:
1782
        - translation (Optional[np.ndarray]): A 3-element translation vector
1783
        - rotation (Optional[np.ndarray]): A 3x3 rotation matrix, Euler angles $(R_x,R_y,R_z)$ or a rotation quaternion $(q_x,q_y,q_z,q_w)$.
1784

1785
    Returns:
1786
        cartesianTransform (np.ndarray): The 4x4 transformation matrix
1787
    """   
1788
    # Initialize identity rotation matrix and zero translation vector
1789
    r = np.eye(3)
1✔
1790
    t = np.zeros((3, 1))
1✔
1791

1792
    # Update rotation matrix if provided
1793
    if rotation is not None:
1✔
1794
        rotation=np.asarray(rotation)
1✔
1795
        if rotation.size == 3: #Euler angles
1✔
1796
            r = R.from_euler('xyz', rotation,degrees=True).as_matrix()
1✔
1797
        elif rotation.size == 9: #rotation matrix
1✔
1798
            r = np.reshape(np.asarray(rotation), (3, 3))
1✔
1799
        elif rotation.size == 4: #quaternion
1✔
1800
            r = R.from_quat(rotation).as_matrix()
1✔
1801
        else:
1802
            raise ValueError("Rotation must be either a 3x3 matrix or a tuple/list of three Euler angles.")
×
1803

1804
    # Update translation vector if provided
1805
    if translation is not None:
1✔
1806
        t = np.reshape(np.asarray(translation), (3, 1))
1✔
1807

1808
    # Create the last row of the transformation matrix
1809
    h = np.array([[0, 0, 0, 1]])
1✔
1810

1811
    # Concatenate rotation and translation to form the 3x4 upper part of the transformation matrix
1812
    transformation = np.hstack((r, t))
1✔
1813

1814
    # Add the last row to make it a 4x4 matrix
1815
    transformation = np.vstack((transformation, h))
1✔
1816

1817
    return transformation
1✔
1818

1819
def get_convex_hull(geometry, thickness=1e-3, distanceTreshold = 10000):
1✔
1820
    """Computes the Convex Hull for PointCloud, Mesh, LineSet, or NumPy array."""
1821
    points = extract_points(geometry)
1✔
1822
    # If input is already an OBB, extract box points
1823
    if isinstance(points, o3d.geometry.OrientedBoundingBox):
1✔
1824
        points = np.asarray(points.get_box_points())
1✔
1825
    points = preprocess_points_for_geometry(points, thickness)
1✔
1826

1827
    # Center the points to improve numerical stability
1828
    center = np.mean(points, axis=0)
1✔
1829
    if(np.linalg.norm(center) > distanceTreshold): # points are really far away
1✔
1830
        points_centered = points - center
1✔
1831
    else: points_centered = points
1✔
1832

1833
    # Compute convex hull on centered points
1834
    pcd = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(points_centered))
1✔
1835
    hull, _ = pcd.compute_convex_hull()
1✔
1836

1837
    # Translate back to original space
1838
    if(np.linalg.norm(center) > distanceTreshold): # points are really far away
1✔
1839
        hull.translate(center)
1✔
1840
    hull.paint_uniform_color([0.0, 1.0, 0.0])  # Green for visualization
1✔
1841

1842
    return hull
1✔
1843

1844
def get_geometry_from_path(path : str) -> o3d.geometry.Geometry:
1✔
1845
    """Gets a open3d Geometry from a path.
1846

1847
    Args:
1848
        path (str): The absulute path to the resource\n
1849

1850
    Returns:
1851
        o3d.geometry: open3d.Pointcloud or open3d.Trianglemesh, depending on the extension
1852
    """
1853
    newGeometry = None
×
1854
    if(path.endswith(tuple(ut.MESH_EXTENSION))):
×
1855
        newGeometry = o3d.io.read_triangle_mesh(path)
×
1856
        if(not newGeometry.has_triangles()):
×
1857
            newGeometry = o3d.io.read_point_cloud(path)
×
1858
        #if not newGeometry.has_vertex_normals():
1859
        else:
1860
            newGeometry.compute_vertex_normals()
×
1861
    elif(path.endswith(tuple(ut.PCD_EXTENSION))):
×
1862
        newGeometry = o3d.io.read_point_cloud(path)
×
1863
    return newGeometry
×
1864

1865

1866
def get_mesh_collisions_trimesh(sourceMesh: o3d.geometry.TriangleMesh, geometries: List[o3d.geometry.TriangleMesh]) -> List[int]:
1✔
1867
    """Return indices of geometries that collide with the source.\n
1868

1869
    .. image:: ../../../docs/pics/collision_4.PNG
1870

1871
    Args:
1872
        1. sourceMesh (o3d.geometry.TriangleMesh)\n
1873
        2. geometries (List[o3d.geometry.TriangleMesh])
1874

1875
    Returns:
1876
        List[int]: indices of inliers.
1877
    """
1878
    if 'TriangleMesh' in str(type(sourceMesh)) and len(sourceMesh.triangles) >0:
×
1879
        myTrimesh=mesh_to_trimesh(sourceMesh)
×
1880
        geometries=ut.item_to_list(geometries)
×
1881
        # add all geometries to the collision manager
1882
        collisionManager=trimesh.collision.CollisionManager()
×
1883
        for idx,geometry in enumerate(geometries):
×
1884
            if 'TriangleMesh' in str(type(geometry)) and len(geometry.triangles) >1:
×
1885
                referenceTrimesh=mesh_to_trimesh(geometry)
×
1886
                collisionManager.add_object(idx,referenceTrimesh)
×
1887

1888
        # report the collisions with the sourceMesh
1889
        (is_collision, names ) = collisionManager.in_collision_single(myTrimesh, transform=None, return_names=True, return_data=False)    
×
1890
        if is_collision:
×
1891
            list=[int(name) for name in names]
×
1892
            return list
×
1893
    else:
1894
        raise ValueError('condition not met: type(sourceMesh) is o3d.geometry.TriangleMesh and len(sourceMesh.triangles) >0')
×
1895

1896
def get_mesh_inliers(sources:List[o3d.geometry.TriangleMesh], reference:o3d.geometry.TriangleMesh) -> List[int]:
1✔
1897
    """Returns the indices of the geometries that lie within a reference geometry. The watertightness of both source and reference geometries is determined after which the occupancy is computed for a numer of sampled points in the source geometries. \n
1898

1899
    **NOTE**: all source ponts/vertices should lie within the reference, else this is False.\n 
1900

1901
    .. image:: ../../../docs/pics/selection_BB_mesh5.PNG
1902
    
1903
    Args:
1904
        1. sources (List[o3d.geometry.TriangleMesh or o3d.geometry.PointCloud]): geometries to test for collision\n
1905
        2. reference (o3d.geometry.TriangleMesh or o3d.geometry.PointCloud): reference geometry \n
1906

1907
    Returns:
1908
        List[int]: Indices of the inlier geometries
1909
    """
1910
    #validate inputs    
1911
    sources=ut.item_to_list(sources)
1✔
1912
    #compute watertight hull if necessary
1913
    reference,_=(reference,None) if ('TriangleMesh' in str(type(reference)) and reference.is_watertight()) else reference.compute_convex_hull()
1✔
1914
    
1915
    #create raycasting scene
1916
    scene = o3d.t.geometry.RaycastingScene()
1✔
1917
    reference = o3d.t.geometry.TriangleMesh.from_legacy(reference)
1✔
1918
    _ = scene.add_triangles(reference)
1✔
1919
    #compute inliers
1920
    inliers=[None]*len(sources)
1✔
1921
    for i,source in enumerate(sources):
1✔
1922
        source,_=(source,None) if ('TriangleMesh' in str(type(source)) and source.is_watertight()) else source.compute_convex_hull()
1✔
1923
        query_points = o3d.core.Tensor(np.asarray(source.vertices), dtype=o3d.core.Dtype.Float32)
1✔
1924
        occupancy = scene.compute_occupancy(query_points)        
1✔
1925
        inliers[i]=True if np.any(occupancy.numpy()) else False       
1✔
1926
    #select indices    
1927
    ind=np.where(np.asarray(inliers) ==True)[0]     
1✔
1928
    return ind
1✔
1929

1930
def get_oriented_bounding_box(geometry, thickness=1e-3, distanceTreshold = 10000):
1✔
1931
    """Computes the Oriented Bounding Box (OBB) for PointCloud, Mesh, LineSet, or NumPy array."""
1932
    points = extract_points(geometry)
1✔
1933
    # If already an OBB, just return it
1934
    if isinstance(points, o3d.geometry.OrientedBoundingBox):
1✔
1935
        return points
1✔
1936
    points = preprocess_points_for_geometry(points, thickness)
1✔
1937

1938
    # Compute center and shift points
1939
    center = np.mean(points, axis=0)
1✔
1940
    if(np.linalg.norm(center) > distanceTreshold): # points are really far away
1✔
1941
        points_centered = points - center
1✔
1942
    else: points_centered = points
1✔
1943

1944
    # Convert to Open3D point cloud and compute OBB
1945
    pcd = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(points_centered))
1✔
1946
    obb = pcd.get_oriented_bounding_box()
1✔
1947

1948
    # Translate OBB back to original location
1949
    if(np.linalg.norm(center) > distanceTreshold): # points are really far away
1✔
1950
        obb.translate(center)
1✔
1951

1952
    return obb
1✔
1953

1954

1955

1956
def get_oriented_bounding_box_parameters(orientedBoundingBox: o3d.geometry.OrientedBoundingBox)->np.ndarray:
1✔
1957
    """
1958
    Extract the center, extent, and Euler angles from an Open3D oriented bounding box.
1959

1960
    Parameters:
1961
    obb (o3d.geometry.OrientedBoundingBox): The oriented bounding box from which to extract parameters.
1962

1963
    Returns:
1964
    tuple: A tuple containing the center (list), extent (list), and Euler angles (list in degrees).
1965
    """
1966
    center = orientedBoundingBox.center
1✔
1967
    extent = orientedBoundingBox.extent
1✔
1968
    rotation_matrix = copy.deepcopy(orientedBoundingBox.R)
1✔
1969
    euler_angles = R.from_matrix(rotation_matrix).as_euler('xyz', degrees=True)
1✔
1970
    print("The euler angles are derived from the rotation matrix, please note that this representation has a number of disadvantages")
1✔
1971
    return np.hstack((center, extent, euler_angles))
1✔
1972

1973
def get_oriented_bounds(cartesianBounds:np.array) -> List[o3d.utility.Vector3dVector]:
1✔
1974
    """Get 8 bounding box from cartesianBounds.\n
1975

1976
    Args:
1977
        cartesianBounds (np.array[6x1])
1978

1979
    Returns:
1980
        List[o3d.utility.Vector3dVector]
1981
    """    
1982
    array=np.empty((8,3))
1✔
1983
    xMin=cartesianBounds[0]
1✔
1984
    xMax=cartesianBounds[1]
1✔
1985
    yMin=cartesianBounds[2]
1✔
1986
    yMax=cartesianBounds[3]
1✔
1987
    zMin=cartesianBounds[4]
1✔
1988
    zMax=cartesianBounds[5]
1✔
1989

1990
    #same order as Open3D
1991
    array[0]=np.array([xMin,yMin,zMin])
1✔
1992
    array[1]=np.array([xMax,yMin,zMin])
1✔
1993
    array[2]=np.array([xMin,yMax,zMin])
1✔
1994
    array[3]=np.array([xMin,yMin,zMax])
1✔
1995
    array[4]=np.array([xMax,yMax,zMax])
1✔
1996
    array[5]=np.array([xMin,yMax,zMax])
1✔
1997
    array[6]=np.array([xMax,yMin,zMax])
1✔
1998
    array[7]=np.array([xMax,yMax,zMin])
1✔
1999

2000
    return o3d.utility.Vector3dVector(array)
1✔
2001
    
2002

2003
def get_pcd_collisions(sourcePcd: o3d.geometry.PointCloud, geometries: List[o3d.geometry.PointCloud]) -> List[int]:
1✔
2004
    """Return indices of geometries that collide with the source. This detection is based on the convex hull of the geometries and the sourcePcd.\n
2005

2006
    Args:
2007
        1. sourceMesh (o3d.geometry.TriangleMesh)\n
2008
        2. geometries (List[o3d.geometry.TriangleMesh])\n
2009

2010
    Returns:
2011
        List[int]: indices
2012
    """
2013
    geometries=ut.item_to_list(geometries)
×
2014
    sourceHull, _ = sourcePcd.compute_convex_hull()
×
2015
    hulls,_= map(list, zip(*[g.compute_convex_hull() for g in geometries]))   
×
2016
    return get_mesh_collisions_trimesh(sourceMesh=sourceHull, geometries=hulls)
×
2017

2018
def get_pcd_from_depth_map(self)->o3d.geometry.PointCloud:
1✔
2019
    """
2020
    Convert a panoramic depth map and image colors (equirectangular) to a 3D point cloud.
2021
    
2022
    Args:
2023
        - self._depthMap: 2D numpy array containing depth values (equirectangular depth map)
2024
        - self._resource: 2D numpy array containing color values (equirectangular color image)
2025
    
2026
    Returns:
2027
        - An Open3D point cloud object
2028
    """
2029
    if not isinstance(self._depthMap,np.ndarray):
×
2030
        return None
×
2031
    
2032
    # Get the dimensions of the depth map
2033
    height, width = self._depthMap.shape
×
2034
    
2035
    # Check if the color image and depth map have the same dimensions
2036
    resource=cv2.resize(self._resource,(self._depthMap.shape[1],self._depthMap.shape[0])) if (isinstance(self._resource,np.ndarray) and not self._resource.shape[0] == self._depthMap.shape[0]) else self._resource
×
2037

2038
    # field of view in radians
2039
    fov_horizontal_rad =  2*np.pi
×
2040
    fov_vertical_rad = np.pi
×
2041

2042
    # Generate arrays for pixel coordinates
2043
    # u: horizontal pixel coordinates (0 to width-1), v: vertical pixel coordinates (0 to height-1)
2044
    u = np.linspace(0, width - 1, width)
×
2045
    v = np.linspace(0, height - 1, height)#[::-1]  # Flip vertically (top to bottom)
×
2046
    u, v = np.meshgrid(u, v)
×
2047

2048
    # Map pixels to spherical coordinates
2049
    # Azimuth (longitude) theta is mapped from 0 to 2*pi across the width of the image
2050
    theta = u / (width - 1) * fov_horizontal_rad - np.pi  # Map [0, width-1] to [-pi, pi]
×
2051

2052
    # Elevation (latitude) phi is mapped from 0 to pi across the height of the image
2053
    phi = v / (height - 1) * fov_vertical_rad - (np.pi / 2)  # Map [0, height-1] to [-pi/2, pi/2]
×
2054

2055
    # Spherical to Cartesian conversion in camera coordinate system (z-forward, y-up)
2056
    x = self._depthMap * np.cos(phi) * np.sin(theta)    # x-axis (left-right in pinhole model)
×
2057
    y = self._depthMap * -np.sin(phi)                   # y-axis (up-down in pinhole model)
×
2058
    z = self._depthMap * np.cos(phi) * np.cos(theta)    # z-axis (forward-backward in pinhole model)
×
2059
    
2060
    # Flatten the x, y, z arrays to create a list of points
2061
    points = np.stack((x.flatten(order='C'), y.flatten(order='C'), z.flatten(order='C')), axis=-1)
×
2062
    
2063
    # Flatten the color image to correspond to the points
2064
    colors=None
×
2065
    if isinstance(resource,np.ndarray):
×
2066
        colors = resource.reshape(-1, 3, order='C')  # Ensure row-major order (C-style)
×
2067
        #divide by 255 if the colors are in the range 0-255
2068
        colors = colors / 255 if np.max(colors) > 1 else colors
×
2069
    
2070
    # Create an Open3D point cloud from the points
2071
    pcd = o3d.geometry.PointCloud()
×
2072
    pcd.points = o3d.utility.Vector3dVector(points)
×
2073
    if isinstance(colors,np.ndarray):
×
2074
        pcd.colors = o3d.utility.Vector3dVector(colors)
×
2075

2076
    # #note that Z goes through the center of the image, so we need to rotate the point cloud 90° clockwise around the x-axis
2077
    # r = R.from_euler('x', -90, degrees=True).as_matrix()
2078
    # pcd.rotate(r,center = [0,0,0])
2079
    
2080
    #transform to the cartesianTransform
2081
    pcd.transform(self._cartesianTransform) if self._cartesianTransform is not None else None
×
2082
    return pcd
×
2083

2084
def get_points_and_normals(pcd,transform:np.ndarray=None,getNormals=False)-> Tuple[np.ndarray,np.ndarray]:
1✔
2085
    """Extract points from different point cloud formats. Optionally extract or generate normals and apply a rigid body transformation.
2086

2087
    Args:
2088
        1. pcd (_type_): point cloud \n
2089
        2. transform (np.array[4,4], optional): Rigid body transformation. Defaults to None.\n
2090
        3. getNormals (bool, optional): Defaults to False.\n
2091

2092
    Raises:
2093
        ValueError: Only open3d, laspy and pandas data formats are currently supported.\n
2094

2095
    Returns:
2096
        Tuple[np.array,np.array]: points, normals
2097
    """
2098
    if 'LasData' in str(type(pcd)):
1✔
2099
        points= transform_points(pcd.xyz,transform) if transform is not None else pcd.xyz
×
2100
        normals= las_get_normals(pcd,transform) if getNormals else None
×
2101
    elif 'PointCloud' in str(type(pcd)):
1✔
2102
        pcd.transform(transform) if transform is not None else None
1✔
2103
        points=np.asarray(pcd.points) 
1✔
2104
        normals=pcd_get_normals(pcd) if getNormals else None
1✔
2105
    elif 'DataFrame' in str(type(pcd)):
×
2106
        raise NotImplementedError("Dataframe Query_points is not implemented yet")
×
2107
        # TODO: implement dataframe query_points and query points
2108
    else:
2109
        raise ValueError('type(pcd) == o3d.geometry.PointCloud, laspy point cloud or pandas dataframe')
×
2110
    return points,normals
1✔
2111

2112
def get_rotation_matrix_from_forward_up(forward: np.ndarray, up: np.ndarray) -> np.ndarray:
1✔
2113
    """
2114
    Compute a rotation matrix from a forward and an up vector. (right, up, forward)
2115

2116
    Args:
2117
        forward (np.ndarray): A 3-element array representing the forward direction.
2118
        up (np.ndarray): A 3-element array representing the up direction.
2119

2120
    Returns:
2121
        np.ndarray: A 3x3 rotation matrix.
2122
    """
2123
    # Normalize the vectors
2124
    forward = forward / np.linalg.norm(forward)
1✔
2125

2126
    # Compute the right vector as the cross product of up and forward
2127
    right = np.cross(up, forward)
1✔
2128
    right /= np.linalg.norm(right)
1✔
2129
    
2130
    # Recompute the up vector to ensure orthogonality
2131
    up = np.cross(forward, right)
1✔
2132
    
2133
    # Construct the rotation matrix
2134
    rotation_matrix = np.column_stack((right, up, forward))
1✔
2135
    
2136
    return rotation_matrix
1✔
2137
def get_rotation_matrix(data:np.array) -> np.array:
1✔
2138
    """Get rotation matrix from one of the following inputs.\n
2139

2140
    Args:
2141
        1. cartesianTransform (np.array [4x4])\n
2142
        2. Euler angles (np.array[3x1]): yaw, pitch, roll  (in degrees)\n
2143
        3. quaternion (np.array[4x1]): w,x,y,z\n
2144
        4. orientedBounds(np.array[8x3])\n
2145

2146
    Returns:
2147
        rotationMatrix (np.array[3x3])
2148
    """  
2149
    if data.size ==16:
1✔
2150
        return data[0:3,0:3]
1✔
2151
    elif data.size == 4:
×
2152
        r = R.from_quat(data)
×
2153
        return r.as_matrix()
×
2154
    elif data.size ==3:
×
2155
        r = R.from_euler('zyx',data,degrees=True)
×
2156
        return r.as_matrix()
×
2157
    elif data.size == 24:
×
2158
        boxPointsVector = o3d.utility.Vector3dVector(data)
×
2159
        open3dOrientedBoundingBox = o3d.geometry.OrientedBoundingBox.create_from_points(boxPointsVector)
×
2160
        return np.asarray(open3dOrientedBoundingBox.R)    
×
2161
    else:
2162
        raise ValueError('No suitable input array found')
×
2163

2164
def get_translation(data)-> np.array:
1✔
2165
    """Get translation vector from various inputs.\n
2166

2167
    Args:
2168
        0. cartesianTransform (np.array [4x4])\n
2169
        1. cartesianBounds (np.array[6x1])\n
2170
        2. orientedBounds(np.array[8x3])\n
2171
        3. Open3D geometry\n
2172

2173
    Raises:
2174
        ValueError: data.size !=6 (cartesianBounds), data.shape[1] !=3 (orientedBounds), data.size !=16 (cartesianTransform) or type != Open3D.geometry
2175

2176
    Returns:
2177
        np.array[3x1]
2178
    """
2179
    if data.size ==6: #cartesianBounds
1✔
2180
        x=(data[1]+data[0])/2
1✔
2181
        y=(data[3]+data[2])/2
1✔
2182
        z=(data[5]+data[4])/2
1✔
2183
        return np.array([x,y,z])
1✔
2184
    elif data.shape[1] ==3:   #orientedBounds
1✔
2185
        return np.mean(data,axis=0)
1✔
2186
    elif data.size ==16: #cartesianTransform
1✔
2187
        return data[0:3,3]
1✔
2188
    elif 'Open3D' in str(type(data)): #Open3D geometry
×
2189
        return data.get_center()
×
2190
    else:
2191
        raise ValueError(" data.size !=6 (cartesianBounds), data.shape[1] !=3 (orientedBounds), data.size !=16 (cartesianTransform) or type != Open3D.geometry")
×
2192

2193
def get_triangles_center(mesh:o3d.geometry.TriangleMesh,triangleIndices: List[int]=None) -> np.array:
1✔
2194
    """Get the centerpoints of a set of mesh triangles.\n
2195

2196
    Args:
2197
        1. mesh (o3d.geometry.TriangleMesh)\n
2198
        2. triangleIndices (List[int], optional): Indices to evaluate. Defaults to all triangles\n
2199

2200
    Raises:
2201
        ValueError: len(triangleIndices)>len(mesh.triangles)\n
2202
        ValueError: all(x > len(mesh.triangles) for x in triangleIndices)\n
2203

2204
    Returns:
2205
        np.array[nx3] XYZ centers of triangles
2206
    """
2207
    #validate inputs
2208
    triangleIndices=range(0,len(mesh.triangles)) if not triangleIndices else triangleIndices
1✔
2209
    assert len(triangleIndices)<=len(mesh.triangles)
1✔
2210
    
2211
    #get triangles and compute center
2212
    triangles=np.asarray([triangle for idx,triangle in enumerate(mesh.triangles) if idx in triangleIndices])
1✔
2213
    centers=np.empty((len(triangles),3))
1✔
2214
    for idx,row in enumerate(triangles):
1✔
2215
        points=np.array([mesh.vertices[row[0]],mesh.vertices[row[1]],mesh.vertices[row[2]]])
1✔
2216
        centers[idx]=np.mean(points,axis=0)
1✔
2217
    return centers
1✔
2218

2219
def ifc_to_mesh(ifcElement:ifcopenshell.entity_instance)-> o3d.geometry.TriangleMesh: 
1✔
2220
    """Convert an ifcOpenShell geometry to an Open3D TriangleMesh.\n
2221

2222
    Args:
2223
        ifcElement (ifcopenshell.entity_instance): IfcOpenShell Element parsed from and .ifc file. See BIMNode for more documentation.
2224

2225
    Raises:
2226
        ValueError: Geometry production error. This function throws an error if no geometry can be parsed for the ifcElement.  
2227

2228
    Returns:
2229
        o3d.geometry.TriangleMesh: Open3D Mesh Geometry of the ifcElment boundary surface
2230
    """    
2231
    try:
1✔
2232
        if ifcElement.get_info().get("Representation"): 
1✔
2233
            # Set geometry settings and global coordinates as true
2234
            settings = geom.settings() 
1✔
2235
            settings.set(settings.USE_WORLD_COORDS, True) 
1✔
2236
        
2237
            # Extract vertices/faces of the IFC geometry
2238
            shape = geom.create_shape(settings, ifcElement) 
1✔
2239
            ifcVertices = shape.geometry.verts 
1✔
2240
            ifcFaces = shape.geometry.faces 
1✔
2241

2242
            #Group the vertices and faces in a way they can be read by Open3D
2243
            vertices = [[ifcVertices[i], ifcVertices[i + 1], ifcVertices[i + 2]] for i in range(0, len(ifcVertices), 3)]
1✔
2244
            faces = [[ifcFaces[i], ifcFaces[i + 1], ifcFaces[i + 2]] for i in range(0, len(ifcFaces), 3)]
1✔
2245

2246
            #Convert grouped vertices/faces to Open3D objects 
2247
            o3dVertices = o3d.utility.Vector3dVector(np.asarray(vertices))
1✔
2248
            o3dTriangles = o3d.utility.Vector3iVector(np.asarray(faces))
1✔
2249

2250
            # Create the Open3D mesh object
2251
            mesh=o3d.geometry.TriangleMesh(o3dVertices,o3dTriangles)
1✔
2252
            if len(mesh.triangles)>1:
1✔
2253
                return mesh
1✔
2254
            else: 
2255
                return None 
×
2256
    except:
×
2257
        print('Geometry production error')
×
2258
        return None
×
2259
def ifc_get_materials(ifcElements:List[ifcopenshell.entity_instance])-> List[str]: 
1✔
2260
    """Get ifc materials from an ifcElement
2261

2262
    Args:
2263
        ifcElements (List[ifcopenshell.entity_instance])
2264

2265
    Returns:
2266
        List[str]: names of materials
2267
    """
2268
    material_list=[]
×
2269
    for ifcElement in ut.item_to_list(ifcElements):
×
2270
        if ifcElement.HasAssociations:
×
2271
            for i in ifcElement.HasAssociations:
×
2272
                if i.is_a('IfcRelAssociatesMaterial'):
×
2273
                    if i.RelatingMaterial.is_a('IfcMaterial'):
×
2274
                        material_list.append(i.RelatingMaterial.Name)
×
2275

2276
                    if i.RelatingMaterial.is_a('IfcMaterialList'):
×
2277
                        for materials in i.RelatingMaterial.Materials:
×
2278
                            material_list.append(materials.Name)
×
2279

2280
                    if i.RelatingMaterial.is_a('IfcMaterialLayerSetUsage'):
×
2281
                        for materials in i.RelatingMaterial.ForLayerSet.MaterialLayers:
×
2282
                            material_list.append(materials.Material.Name)
×
2283
    return material_list
×
2284

2285
def img_to_arrays(path:str,tasknr:int=0)->Tuple[np.array,int]:
1✔
2286
    """Convert an image from a file path to a tuple of 1 np.arrays and a tasknr (this function is used for multi-processing).\n
2287

2288
    Args:
2289
        1. path (str): path to mesh file \n
2290
        2. tasknr(int,optional): tasknr used to keep the order in multiprocessing.\n
2291

2292
    Returns:
2293
        Tuple[img (np.array), tasknr (int)]
2294
    """
2295
    path = str(path)
×
2296
    img=cv2.imread(path)
×
2297
    return img, tasknr
×
2298

2299
def join_geometries(geometries:List[o3d.geometry.Geometry])->o3d.geometry.Geometry:
1✔
2300
    """Join together a number of o3d geometries e.g. LineSet, PointCloud or o3d.TriangleMesh instances.
2301

2302
    **NOTE**: Only members of the same geometryType can be merged.
2303
    
2304
    **NOTE**: np.arrays can also be processed (these are processed as point clouds)
2305

2306
    Args:
2307
        - geometries (List[o3d.geometry.Geometry]) : LineSet, PointCloud, OrientedBoundingBox, TriangleMesh or np.array[nx3]
2308

2309
    Returns:
2310
        merged o3d.geometries
2311
    """
2312
    if not geometries:
1✔
2313
        raise ValueError("The geometries list is empty.")
×
2314
    
2315
    geom_type = type(geometries[0])
1✔
2316
    if not all(isinstance(g, geom_type) for g in geometries):
1✔
2317
        raise TypeError("All geometries must be of the same type.")
×
2318
    
2319
    if geom_type == o3d.geometry.PointCloud:
1✔
2320
        merged = o3d.geometry.PointCloud()
1✔
2321
        for g in geometries:
1✔
2322
            merged += g
1✔
2323
        return merged
1✔
2324
    
2325
    elif geom_type == o3d.geometry.LineSet:
1✔
2326
        merged = o3d.geometry.LineSet()
×
2327
        for g in geometries:
×
2328
            merged += g
×
2329
        return merged
×
2330
    
2331
    elif geom_type == o3d.geometry.TriangleMesh:
1✔
2332
        merged = o3d.geometry.TriangleMesh()
1✔
2333
        for g in geometries:
1✔
2334
            merged += g
1✔
2335
        return merged
1✔
2336
    
2337
    elif isinstance(geometries[0], np.ndarray):
×
2338
        merged = np.vstack(geometries)
×
2339
        return o3d.geometry.PointCloud(o3d.utility.Vector3dVector(merged))
×
2340
    
2341
    else:
2342
        raise TypeError("Unsupported geometry type for merging.")
×
2343
        
2344

2345
def las_to_pcd(las:laspy.LasData, transform:np.array=None, getColors:bool=True,getNormals:bool=False)->o3d.geometry.PointCloud:
1✔
2346
    """Converts a laspy point cloud to an open3d point cloud.\n
2347

2348
    Args:
2349
        1. las (laspy.LasData): laspy point cloud.\n
2350
        2. transform (np.array[4x4], optional): offset transform i.e. to remove global coordinates. Defaults to None.\n
2351
        3. getColors (bool, optional): Defaults to True.\n
2352
        4. getNormals (bool, optional): Defaults to False.\n
2353

2354
    Returns:
2355
        o3d.geometry.PointCloud
2356
    """
2357
    #validate transform
2358
    if transform is not None:
1✔
2359
        assert transform.shape[0]==4
×
2360
        assert transform.shape[1]==4
×
2361
    
2362
    #create point cloud
2363
    pcd = o3d.geometry.PointCloud()    
1✔
2364
    newxyz=transform_points( las.xyz,transform) if transform is not None else las.xyz
1✔
2365
    pcd.points=o3d.utility.Vector3dVector(newxyz)
1✔
2366
    
2367
    #compute colors
2368
    if (all(elem.casefold() in las.point_format.dimension_names for elem in ['red', 'green', 'blue'])):
1✔
2369
        red = las['red']
1✔
2370
        green = las['green']
1✔
2371
        blue = las['blue']
1✔
2372
        #if color is 32 bit, only keep 8 bit color
2373
        if red.max()>255:
1✔
2374
            red = las['red'] >> 8 & 0xFF
1✔
2375
            green = las['green'] >> 8 & 0xFF
1✔
2376
            blue = las['blue'] >> 8 & 0xFF
1✔
2377
        # if colorspace is [0-255] -> remap to [0-1]
2378
        if red.max() >1:
1✔
2379
            red=red/255
1✔
2380
            green=green/255
1✔
2381
            blue=blue/255
1✔
2382
        pcd.colors=o3d.utility.Vector3dVector(np.vstack((red,green,blue)).transpose())
1✔
2383
        
2384
    #compute normals
2385
    if getNormals:
1✔
2386
        if all(n in ['normalX','normalY','normalZ'] for n in list(las.point_format.dimension_names)):
1✔
2387
            normals=np.hstack((las['normalX'],las['normalY'],las['normalZ']))
×
2388
            newNormals=transform_points(normals,transform) if transform is not None else normals
×
2389
            pcd.normals=o3d.utility.Vector3dVector(newNormals)
×
2390
        else:
2391
            pcd.estimate_normals()
1✔
2392
    return pcd
1✔
2393

2394
def las_add_extra_dimensions(las:laspy.LasData, recordData:np.array, names:List[str]=['newField'], dtypes:List[str]=["uint8"] )-> laspy.LasData:
1✔
2395
    """Add one or more columns of data to an existing las point cloud file.\n
2396
    View laspy dimension and type formatting at https://laspy.readthedocs.io/en/latest/lessbasic.html.\n
2397
    
2398
    **NOTE**: to be tested
2399
    
2400
    Args:
2401
        1. las (laspy.LasData): las point cloud (laspy API).\n
2402
        2. recordData (tuple with np.array[len(pcd.points)] or pandas.DataFrame): array or dataframe with len(columns) == len(pcd.points).\n
2403
        3. names (str, optional): dimension names. Defaults to ['newField'].\n
2404
        4. dtypes (str, optional): types of the columns. Defaults to ["uint8"].\n
2405

2406
    Returns:
2407
        laspy.LasData: _description_
2408
    """
2409
    #validate inputs
2410
    names=ut.item_to_list(names)
×
2411
    dtypes=ut.item_to_list(dtypes)
×
2412
    assert 'las' in str(type(las))
×
2413
    assert len(names)==len(dtypes)    
×
2414
    if 'array' in str(type(recordData)):
×
2415
        assert recordData.shape[0]==las.x.shape[0] ,f'one of the recordData contains entries != (len(las.xyz)).'
×
2416
        assert recordData.shape[1]==len(names), f'recordData.shape[1] !=len(names).'
×
2417
    elif 'dataframe' in str(type(recordData)):
×
2418
        assert len(recordData)==las.x.shape[0],f'one of the recordData contains entries != (len(las.xyz)).'
×
2419
        assert len(recordData.columns)==len(names), f'recordData.shape[1] !=len(names).'
×
2420
    elif 'Tuple' in str(type(recordData)):
×
2421
        assert len(recordData)==len(names), f'len(recordData) !=len(names).'
×
2422
        
2423
    #create dimensions
2424
    extraBytesParams=[laspy.ExtraBytesParams(name=name, type=dtype) for name,dtype in zip(names,dtypes)]
×
2425
    las.add_extra_dims(extraBytesParams)   
×
2426

2427
    #add data
2428
    if 'array' in str(type(recordData[0])):
×
2429
        [setattr(las,name,recordData[i]) for i,name in enumerate(names)]
×
2430
    elif 'dataframe' in str(type(recordData)):
×
2431
        [setattr(las,name,recordData[[i]]) for i,name in enumerate(names)]
×
2432
    return las
×
2433

2434
def las_get_data(las,indices:np.ndarray=None,excludedList:List[str]=None)->np.ndarray:
1✔
2435
    """Get all the relevant data from a las file i.e. the points, colors, intensity and user assigned values such as the classification or features.
2436

2437
    Args:
2438
        las (laspy.Laspy): point cloud to extract the data from
2439
        indices (np.ndarray): array with indices to extract
2440
        excludedList (List[str], optional): List with point fields to exlude. []'X', 'Y', 'Z','red', 'green', 'blue'] should be removed as they are automatically assigned if present. Other values that are excluded are ['X', 'Y', 'Z','red', 'green', 'blue','return_number', 'number_of_returns', 'synthetic', 'key_point', 
2441
            'withheld', 'overlap', 'scanner_channel', 'scan_direction_flag', 
2442
            'edge_of_flight_line', 'user_data', 'scan_angle', 'point_source_id', 'gps_time'].
2443

2444
    Returns:
2445
        np.array (points,colors, user_defined_values)
2446
    """
2447
    # get dimension names
2448
    dimension_names=list(las.point_format.dimension_names)
×
2449

2450
    # remove unwanted dimension names
2451
    excludedList=None
×
2452
    excludedList=['X', 'Y', 'Z','red', 'green', 'blue','return_number', 'number_of_returns', 'synthetic', 'key_point', 
×
2453
                'withheld', 'overlap', 'scanner_channel', 'scan_direction_flag', 
2454
                'edge_of_flight_line', 'user_data', 'scan_angle', 'point_source_id', 'gps_time'] if excludedList is None else excludedList
2455
    dimension_names=[dim for dim in dimension_names if dim not in excludedList]
×
2456

2457
    # gather points
2458
    data=las.xyz 
×
2459
    names=['X', 'Y', 'Z']
×
2460

2461
    # gather color if present
2462
    if las['red'].T.shape[0]==data.shape[0]:
×
2463
        data=np.hstack((data,np.array([las['red']]).T,np.array([las['red']]).T,np.array([las['red']]).T)) 
×
2464
        names.extend(['red', 'green', 'blue'])
×
2465

2466
    # gather other fields if present
2467
    for dim in dimension_names:
×
2468
        if las[dim].T.shape[0]==data.shape[0] :
×
2469
            data=np.hstack((data,np.array([las[dim]]).T)) 
×
2470
            names.append(dim)
×
2471
    
2472
    #get indices if present
2473
    data=data[indices] if indices is not None else data
×
2474
    
2475
    return data,names
×
2476

2477
def las_get_normals(las:laspy.LasData,transform:np.array=None) -> np.array:
1✔
2478
    if all(n in ['normalX','normalY','normalZ'] for n in list(las.point_format.dimension_names)):
×
2479
        normals=np.hstack((las['normalX'],las['normalY'],las['normalZ']))
×
2480
        normals=transform_points(normals,transform) if transform is not None else normals
×
2481
    else:
2482
        normals=np.asarray(las_to_pcd(las,transform,getColors=False,getNormals=True).normals)    
×
2483
    return normals
×
2484

2485
def las_subsample(las: laspy.LasData,percentage:float=0.1) -> laspy.LasData:
1✔
2486
    """Subsample a las file given a percentage [0-1].
2487
    
2488
    The order is assumed to be ['X','Y','Z','red','green','blue','intensity','classification']
2489
    
2490
    **NOTE**: if a classification is present, its maximum value must be <32 (unit4) else the values are remapped
2491
    
2492
    Args:
2493
        1.las ( laspy.LasData): las file
2494
        2.percentage (float, optional): percentage to downsample [0-1].
2495

2496
    Returns:
2497
        laspy.lasdata: output las file 
2498
    """
2499
    #get all las arrays
2500
    array,names=las_get_data(las)
×
2501
    
2502
    #subsample array
2503
    array=array_subsample(array,percentage)
×
2504
        
2505
    #create a new las header
2506
    header = laspy.LasHeader(point_format=3, version="1.2")
×
2507
    header.offsets = np.min(array[:,0:3], axis=0)
×
2508
    header.scales = np.array([0.1, 0.1, 0.1])
×
2509
    new_las = laspy.LasData(header)
×
2510
    
2511
    #fill in las from array    
2512
    new_las.x = array[:, 0]
×
2513
    new_las.y = array[:, 1]
×
2514
    new_las.z = array[:, 2]
×
2515
    array=array[:,3:]
×
2516
    
2517
    #add rgb if present
2518
    if any(n for n in names if n in ['red','green','blue']):
×
2519
        new_las.red=array[:, 0]
×
2520
        new_las.green=array[:, 1]
×
2521
        new_las.blue=array[:, 2]
×
2522
        array=array[:,3:]
×
2523
    
2524
    #add intensity if present
2525
    if any(n for n in names if n in ['intensity']):
×
2526
        new_las.intensity=array[:, 0]
×
2527
        array=array[:,1:]
×
2528
    
2529
    #add classification if present (max 31)
2530
    if any(n for n in names if n in ['classification']):        
×
2531
        new_las.classification=array[:, 0] if array[:, 0].max()<=31 else array[:, 0]-array[:, 0].min()
×
2532
        array=array[:,1:]
×
2533
    
2534
    #create extra dims for scalar fields
2535
    names= [n for n in names if n not in ['X','Y','Z','red','green','blue','intensity','classification']]
×
2536
    dtypes=['float32' for n in names]
×
2537
    new_las=las_add_extra_dimensions(new_las,array,names=names,dtypes=dtypes)    
×
2538
    
2539
    return new_las
×
2540

2541
def mesh_to_arrays(path:str,tasknr:int=0)->Tuple[np.array,np.array,np.array,np.array,int]:
1✔
2542
    """Convert a mesh from a file path to a tuple of 4 np.arrays.\n
2543

2544
    Features:
2545
        0. vertexArray,triangleArray,colorArray,normalArray,tasknr. \n
2546

2547
    Args:
2548
        1. path (str): path to mesh file. \n
2549
        2. tasknr(int,optional): tasknr used to keep the order in multiprocessing.\n
2550

2551
    Returns:
2552
        Tuple[np.array,np.array,np.array,int]: vertexArray,triangleArray,colorArray,normalArray,tasknr
2553
    """
2554
    mesh=o3d.io.read_triangle_mesh(str(path))   
1✔
2555
    vertexArray=np.asarray(mesh.vertices)
1✔
2556
    triangleArray=np.asarray(mesh.triangles)
1✔
2557
    colorArray=np.asarray(mesh.vertex_colors) if mesh.has_vertex_colors() else None
1✔
2558
    normalArray=np.asarray(mesh.vertex_normals) if mesh.has_vertex_normals() else None
1✔
2559
    return vertexArray,triangleArray,colorArray,normalArray,tasknr
1✔
2560
def create_visible_point_cloud_from_meshes (geometries: List[o3d.geometry.TriangleMesh], 
1✔
2561
                                            references:List[o3d.geometry.TriangleMesh], 
2562
                                            resolution:float = 0.1,
2563
                                            getNormals:bool=False)-> Tuple[List[o3d.geometry.PointCloud], List[float]]:
2564
    """Returns a set of point clouds sampled on the geometries. Each point cloud has its points filtered to not lie within or collide with any of the reference geometries. As such, this method returns the **visible** parts of a set of sampled point clouds. \n
2565
    
2566
    For every point cloud, the percentage of visibility is also reported. This method takes about 50s for 1000 geometries. \n
2567
    \n
2568
    E.g. The figure shows the points of the visible point cloud that were rejected due to their proximity to the other mesh geometries.
2569

2570
    .. image:: ../../../docs/pics/invisible_points.PNG
2571

2572
    Args:
2573
        1. geometries (List[o3d.geometry.TriangleMesh]): Meshes that will be sampled up to the resolution. \n
2574
        2. references (List[o3d.geometry.TriangleMesh]): reference meshes that are used to spatially filter the sampled point clouds so only 'visible' points are retained. If some targget \n
2575
        3. resolution (float, optional): Spatial resolution to sample meshes. Defaults to 0.1m. \n
2576

2577
    Raises:
2578
        ValueError: any('TriangleMesh' not in str(type(g)) for g in geometries )\n
2579
        ValueError: any('TriangleMesh' not in str(type(g)) for g in references )\n
2580

2581
    Returns:
2582
        Tuple[List[o3d.geometry.PointCloud], List[percentages [0-1.0]]] per geometry
2583
    """
2584
    geometries=ut.item_to_list(geometries)    
1✔
2585
    references=ut.item_to_list(references)    
1✔
2586

2587
    #validate geometries
2588
    if  any('TriangleMesh' not in str(type(g)) for g in geometries ):
1✔
2589
        raise ValueError('Only submit o3d.geometry.TriangleMesh objects') 
×
2590
    #validate geometries
2591
    if  any('TriangleMesh' not in str(type(g)) for g in references ):
1✔
2592
        raise ValueError('Only submit o3d.geometry.TriangleMesh objects') 
×
2593

2594
    colorArray=np.random.random((len(geometries),3))
1✔
2595
    identityPointClouds=[]
1✔
2596
    percentages=[]
1✔
2597

2598
    for i,geometry in enumerate(geometries):        
1✔
2599
        # create a reference scene 
2600
        referenceGeometries=[g for g in references if g !=geometry ]
1✔
2601
        reference=join_geometries(referenceGeometries)
1✔
2602
        scene = o3d.t.geometry.RaycastingScene()
1✔
2603
        cpuReference = o3d.t.geometry.TriangleMesh.from_legacy(reference)
1✔
2604
        _ = scene.add_triangles(cpuReference)
1✔
2605

2606
        # sample mesh (optional with normals)
2607
        if getNormals and not geometry.has_triangle_normals():
1✔
2608
            geometry.compute_triangle_normals()
×
2609
        area=geometry.get_surface_area()
1✔
2610
        count=int(area/(resolution*resolution))
1✔
2611
        pcd=geometry.sample_points_uniformly(number_of_points=10*count,use_triangle_normal=getNormals)
1✔
2612
        pcd=pcd.voxel_down_sample(resolution)
1✔
2613

2614
        # determine visibility from distance and occupancy querries
2615
        query_points = o3d.core.Tensor(np.asarray(pcd.points), dtype=o3d.core.Dtype.Float32)
1✔
2616
        unsigned_distance = scene.compute_distance(query_points)
1✔
2617
        occupancy = scene.compute_occupancy(query_points)
1✔
2618
        indices=np.where((unsigned_distance.numpy() >=0.5*resolution) & (occupancy.numpy() ==0) )[0]     
1✔
2619

2620
        # crop sampled point cloud to only the visible points
2621
        pcdCropped = pcd.select_by_index(indices)
1✔
2622
        pcdCropped.paint_uniform_color(colorArray[i])
1✔
2623
        identityPointClouds.append(pcdCropped)
1✔
2624

2625
        #report percentage
2626
        percentages.append((len(pcdCropped.points)/len(pcd.points)))
1✔
2627
    return identityPointClouds, percentages
1✔
2628

2629
def mesh_to_trimesh(geometry: o3d.geometry.Geometry) -> trimesh.Trimesh:
1✔
2630
    """Convert open3D.geometry.TriangleMesh to [trimesh.Trimesh](https://trimsh.org/trimesh.html). \n
2631
    
2632
    **NOTE**: Only vertex_colors are implemented instead of face_colors with textures.\n
2633

2634
    Args:
2635
        geometry (Open3D.geometry): OrientedBoundingBox, AxisAlignedBoundingBox or TriangleMesh
2636
    
2637
    Returns:
2638
        trimesh.Trimesh
2639
    """
2640
    if isinstance(geometry, o3d.geometry.TriangleMesh):
1✔
2641
        vertices = np.asarray(geometry.vertices)
1✔
2642
        faces = np.asarray(geometry.triangles)
1✔
2643

2644
        mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
1✔
2645

2646
        # Optionally add vertex normals
2647
        if geometry.has_vertex_normals():
1✔
2648
            mesh.vertex_normals = np.asarray(geometry.vertex_normals)
×
2649

2650
        # Optionally add vertex colors
2651
        if geometry.has_vertex_colors():
1✔
2652
            colors = np.asarray(geometry.vertex_colors)
×
2653
            if colors.max() <= 1.0:  # Open3D uses [0, 1], Trimesh expects [0, 255]
×
2654
                colors = (colors * 255).astype(np.uint8)
×
2655
            mesh.visual.vertex_colors = colors
×
2656

2657
        return mesh
1✔
2658
    
2659
    elif isinstance(geometry, o3d.geometry.PointCloud):
×
2660
        points = np.asarray(geometry.points)
×
2661
        point_cloud = trimesh.points.PointCloud(points)
×
2662

2663
        # Add colors if present
2664
        if geometry.has_colors():
×
2665
            colors = np.asarray(geometry.colors)
×
2666
            if colors.max() <= 1.0:
×
2667
                colors = (colors * 255).astype(np.uint8)
×
2668
            point_cloud.colors = colors
×
2669

2670
        # Add normals if present (stored as metadata, not native in Trimesh PointCloud)
2671
        if geometry.has_normals():
×
2672
            point_cloud.metadata['normals'] = np.asarray(geometry.normals)
×
2673

2674
        return point_cloud
×
2675

2676
    elif isinstance(geometry, o3d.geometry.LineSet):
×
2677
        points = np.asarray(geometry.points)
×
2678
        lines = np.asarray(geometry.lines)
×
2679
        entities = [trimesh.path.entities.Line([start, end]) for start, end in lines]
×
2680
        path = trimesh.path.Path3D(entities=entities, vertices=points)
×
2681

2682
        # Optional: attach colors if present
2683
        if geometry.has_colors():
×
2684
            colors = np.asarray(geometry.colors)
×
2685
            if colors.max() <= 1.0:
×
2686
                colors = (colors * 255).astype(np.uint8)
×
2687
            path.metadata['colors'] = colors
×
2688

2689
        return path
×
2690
    
2691
    else:
2692
        raise TypeError(f"Unsupported Open3D geometry type: {type(geometry)}")
×
2693
    
2694
def mesh_get_lineset(geometry: o3d.geometry.TriangleMesh, color: np.array = ut.get_random_color()) -> o3d.geometry.LineSet:
1✔
2695
    """Returns a lineset representation of a mesh.\n
2696

2697
    Args:
2698
        1. geometry (open3d.geometry.trianglemesh): The mesh to convert. \n
2699
        2. color (np.array[3,], optional): The color to paint the lineset. Defaults to random color.\n
2700

2701
    Returns:
2702
        open3d.geometry.LineSet: the lineset from the mesh.
2703
    """
2704
    assert len(color)==3 
1✔
2705
    ls = o3d.geometry.LineSet.create_from_triangle_mesh(geometry)
1✔
2706
    ls.paint_uniform_color(color)
1✔
2707
    return ls
1✔
2708

2709
def normalize_vectors(array:np.ndarray, axis:int=-1, order:int=2) -> np.ndarray:
1✔
2710
    """Normalize an set of vectors np.array(:,3) to unit vectors len(1).\n
2711

2712
    Args:
2713
        array (np.array(:,3))
2714
        axis (int, optional): Defaults to -1.
2715
        order (int, optional): Defaults to 2.
2716

2717
    Returns:
2718
        np.array(:,3)
2719
    """
2720
    l2 = np.atleast_1d(np.linalg.norm(array, order, axis))
1✔
2721
    l2[l2==0] = 1
1✔
2722
    return array / np.expand_dims(l2, axis)
1✔
2723

2724
def octree_to_voxelmesh(octree:o3d.geometry.Octree)-> o3d.geometry.TriangleMesh:
1✔
2725
    """Create a TriangleMesh from an octree.
2726

2727
    Args:
2728
        octree (o3d.geometry.Octree): size of each node will be used to scale the voxels.
2729

2730
    Returns:
2731
        o3d.geometry.TriangleMesh
2732
    """
2733

2734
    def f_traverse_generate_mesh(node, node_info):
×
2735
        """Callback function that changes the color to the dominant color in each leafnode.
2736
        """
2737
        if isinstance(node, o3d.geometry.OctreeLeafNode):
×
2738
            if isinstance(node, o3d.geometry.OctreePointColorLeafNode):               
×
2739
                cube=o3d.geometry.TriangleMesh.create_box(width=1, height=1, depth=1)
×
2740
                # paint it with the color of the current voxel
2741
                cube.paint_uniform_color(node.color) 
×
2742
                # scale the box using the size of the voxel
2743
                cube.scale(node_info.size, center=cube.get_center())
×
2744
                # translate the box to the center of the voxel
2745
                cube.translate(node_info.origin, relative=False)
×
2746
                # add the box to the TriangleMesh object
2747
                cubes.append(cube) 
×
2748
        return None
×
2749
    cubes=[]
×
2750
    octree.traverse(f_traverse_generate_mesh) 
×
2751
    return join_geometries(cubes)
×
2752
    
2753

2754
def pcd_to_arrays(path:str,percentage:float=1.0,tasknr:int=0)->Tuple[np.array,np.array,np.array,int]:
1✔
2755
    """Convert a pcd from a pcd file to a tuple of 3 np.arrays.\n
2756

2757
    Features:
2758
        1. ['cartesianX', 'cartesianY', 'cartesianZ'] \n
2759
        2. ['colorRed', 'colorGreen', 'colorBlue'] \n
2760
        3. ['normalX', 'normalY', 'normalZ']\n
2761
        4. tasknr (int): int to retrieve order in multiprocessing.\n
2762

2763
    Args:
2764
        1. path (str): path to .pcd file. \n
2765
        2. percentage (float,optional): downsampling ratio. defaults to 1.0 (100%). \n
2766
        3. tasknr (int): int to retrieve order in multiprocessing.\n
2767

2768
    Returns:
2769
        Tuple[np.array,np.array,np.array,np.array]: pointArray,colorArray,normalArray,tasknr
2770
    """
2771
    pcd=o3d.io.read_point_cloud(str(path))   
1✔
2772
    pointArray=np.asarray(pcd.points)
1✔
2773
    colorArray=np.asarray(pcd.colors) if pcd.has_colors() else None
1✔
2774
    normalArray=np.asarray(pcd.normals) if pcd.has_normals() else None    
1✔
2775

2776
    #downnsample
2777
    if percentage <1.0:
1✔
2778
        indices=np.random.randint(0,len(pcd.points)-1,int(len(pcd.points)*percentage))
1✔
2779
        pointArray=pointArray[indices]
1✔
2780
        if pcd.has_colors():
1✔
2781
            colorArray=colorArray[indices]
1✔
2782
        if pcd.has_normals():
1✔
2783
            normalArray=normalArray[indices]
1✔
2784
    return pointArray,colorArray,normalArray,tasknr
1✔
2785

2786
def pcd_to_las(pcd:o3d.geometry.PointCloud,**kwargs)->laspy.LasData:
1✔
2787
    """Convert a dataframe representing a point cloud to a las point cloud file.
2788
    View laspy dimension and type formatting at https://laspy.readthedocs.io/en/latest/lessbasic.html.\n
2789
    
2790
    E.g.: las=dataframe_to_las(dataframe,rgb=[3,4,5])    
2791

2792
    Args:
2793
        1.dataframe (pd.DataFrame): data frame with a number of columns such as xyz, rgb and some scalar fields (conform numpy)
2794
        2.xyz (List[int], optional): Indices of the xyz coordinates in the dataframe. Defaults to [0,1,2].
2795
        3.rgb (List[int], optional): Indices of the color information in the dataframe e.g. [3,4,5]. Defaults to None.
2796
        4.dtypes (List[str], optional): types of the scalar fields that will be added e.g. ['float32','uint8']. Defaults to [float32] equal to the length of the scalar fields.
2797

2798
    Returns:
2799
        laspy.lasdata: output las file 
2800
    """
2801
    #0.get xyz data
2802
    xyz=np.asarray(pcd.points)
×
2803
    
2804
    # 1. Create a new header
2805
    header = laspy.LasHeader(point_format=3, version="1.2")
×
2806
    header.offsets = np.min(xyz, axis=0)
×
2807
    header.scales = np.array([0.1, 0.1, 0.1])
×
2808

2809
    # 2. Create a Las from xyz
2810
    las = laspy.LasData(header)
×
2811

2812
    las.x = xyz[:, 0]
×
2813
    las.y = xyz[:, 1]
×
2814
    las.z = xyz[:, 2]
×
2815
    
2816
    # 3. add rgb if present
2817
    if pcd.has_colors():
×
2818
        rgb=np.asarray(pcd.colors)
×
2819
        las.red=rgb[:, 0]
×
2820
        las.green=rgb[:, 1]
×
2821
        las.blue=rgb[:, 2]
×
2822
        
2823
    # 4. Create extra dims for scalar fields
2824
    names = list(kwargs.keys())
×
2825
    dtypes = [np.asarray(kwargs[name]).dtype for name in names]        
×
2826
    extraBytesParams=[laspy.ExtraBytesParams(name=name, type=dtype) for name,dtype in zip(names,dtypes)]
×
2827
    las.add_extra_dims(extraBytesParams)   
×
2828
    
2829
    # 5. set data      
2830
    [setattr(las,name,np.asarray(kwargs[name])) for name in names]
×
2831
    
2832
    return las
×
2833

2834

2835

2836
def pcd_to_voxelmesh(pcd:o3d.geometry.PointCloud,voxel_size:float=0.4,colorUse:int=0 )-> o3d.geometry.TriangleMesh:
1✔
2837
    """Create a TriangleMesh from a PointCloud.
2838

2839
    Args:
2840
        pcd (o3d.geometry.PointCloud): 
2841
        voxel_size (float, optional): size of the voxels. Defaults to 0.4m.
2842
        colorUse (int, optional): If 0, the colors per voxel will be averaged. If 1, the dominant color per voxel will be retained (this is quite slow). Defaults to 0.
2843

2844
    Returns:
2845
        o3d.geometry.TriangleMesh: _description_
2846
    """
2847
    voxel_grid = o3d.geometry.VoxelGrid.create_from_point_cloud(pcd,  voxel_size)       
×
2848
    colorArray=np.empty((len(voxel_grid.get_voxels()),3))
×
2849
    
2850
    if colorUse==1:
×
2851
        colors=np.asarray(pcd.colors)
×
2852
        graycolors,ind=np.unique(np.dot(colors[...,:3], [0.2989, 0.5870, 0.1140]),return_index=True)
×
2853
        uniquecolors=colors[ind]
×
2854
        
2855
        for i,v in enumerate(voxel_grid.get_voxels()):
×
2856
            c=np.dot(v.color[...,:3], [0.2989, 0.5870, 0.1140])
×
2857
            idx=np.abs(graycolors - c).argmin()
×
2858
            colorArray[i]=uniquecolors[idx]
×
2859
        return voxelgrid_to_mesh(voxel_grid,voxel_size,colorArray)
×
2860
    return voxelgrid_to_mesh(voxel_grid,voxel_size)                                           
×
2861

2862
def pcd_get_normals(pcd:o3d.geometry.PointCloud)->np.ndarray:
1✔
2863
    """Compute open3d point cloud normals if not already present.\n
2864

2865
    Args:
2866
        pcd (o3d.geometry.PointCloud)
2867

2868
    Returns:
2869
        np.array:
2870
    """
2871
    pcd.estimate_normals() if not pcd.has_normals() else None
×
2872
    return np.asarray(pcd.normals)
×
2873

2874
def preprocess_points_for_geometry(points, thickness=1e-3):
1✔
2875
    """Handles special cases: single point, colinear, and coplanar points."""
2876
    # Remove all the duplicate points
2877
    points = np.unique(points, axis=0)
1✔
2878
    
2879
    if len(points) == 1:
1✔
2880
        # Single point: Expand to a small cube
2881
        offsets = np.array([[x, y, z] for x in [-1, 1] for y in [-1, 1] for z in [-1, 1]]) * thickness * 10
×
2882
        return points + offsets
×
2883
    
2884
    elif len(points) == 2 or np.linalg.matrix_rank(points - points.mean(axis=0)) == 1:
1✔
2885
        # Colinear case: Expand perpendicularly
2886
        line_vec = points[-1] - points[0]
1✔
2887
        line_vec = line_vec / np.linalg.norm(line_vec)
1✔
2888

2889
        arbitrary_vec = np.array([1, 0, 0])
1✔
2890
        if np.allclose(line_vec, arbitrary_vec) or np.allclose(line_vec, -arbitrary_vec):
1✔
2891
            arbitrary_vec = np.array([0, 1, 0])
1✔
2892

2893
        perp1 = np.cross(line_vec, arbitrary_vec)
1✔
2894
        perp1 = perp1 / np.linalg.norm(perp1)
1✔
2895
        perp2 = np.cross(line_vec, perp1)
1✔
2896

2897
        return np.vstack([points, points + perp1 * thickness, points - perp1 * thickness,
1✔
2898
                                  points + perp2 * thickness, points - perp2 * thickness])
2899

2900
    elif np.linalg.matrix_rank(points - points.mean(axis=0)) == 2:
1✔
2901
        # Coplanar case: Add minimal thickness
2902
        cov = np.cov(points.T)
1✔
2903
        eigvals, eigvecs = np.linalg.eigh(cov)
1✔
2904
        normal = eigvecs[:, np.argmin(eigvals)]  # Smallest eigenvector
1✔
2905
        return np.vstack([points, points + normal * thickness, points - normal * thickness])
1✔
2906

2907
    return points  # If already 3D, return as-is
1✔
2908
def project_meshes_to_rgbd_images (meshes:List[o3d.geometry.TriangleMesh], extrinsics: List[np.array],intrinsics:List[np.array], scale:float=1.0, fill_black:int=0)->Tuple[List[np.array],List[np.array]]:
1✔
2909
    """Project a set of meshes given camera parameters.
2910

2911
    .. image:: ../../../docs/pics/Raycasting_6.PNG
2912

2913
    Args:
2914
        1.meshes (List[o3d.geometry.TriangleMesh]): set of TriangleMeshes.\n
2915
        2.imgNodes (List[ImageNode]): should contain imageWidth,imageHeight,cartesianTransform and focalLength35mm\n
2916
        3.scale (float, optional): scale to apply to imagery (typically for downscaling). Defaults to 1.\n
2917
        4.fill_black (int, optional): Region to fill in black pixels. 5 is a good value.\n
2918
        
2919
    Returns:
2920
        Tuple[List[np.array],List[np.array]]: colorImages,depthImages
2921
    """
2922
    #validate meshes
2923
    mesh=join_geometries(ut.item_to_list(meshes))
×
2924
    extrinsics=ut.item_to_list(extrinsics)
×
2925
    intrinsics=ut.item_to_list(intrinsics)
×
2926
    
2927
    
2928
    #create lists
2929
    colorImages=[]
×
2930
    depthImages=[]    
×
2931
    
2932
    #create raytracing scene
2933
    scene = o3d.t.geometry.RaycastingScene()
×
2934
    reference=o3d.t.geometry.TriangleMesh.from_legacy(mesh)
×
2935
    scene.add_triangles(reference)
×
2936
    
2937
    #create a colorArray from the mesh triangles (color of first vertex is taken)
2938
    colors=np.asarray(mesh.vertex_colors)
×
2939
    indices=np.asarray(mesh.triangles)[:,0]
×
2940
    triangle_colors=colors[indices]
×
2941
    #append black color at the end of the array for the invalid hits
2942
    triangle_colors=np.vstack((triangle_colors,np.array([0,0,0])))
×
2943
    
2944
    #create rays  
2945
    for e,i in zip(extrinsics,intrinsics):
×
2946
        rays = o3d.t.geometry.RaycastingScene.create_rays_pinhole(
×
2947
                                        intrinsic_matrix =i,
2948
                                        extrinsic_matrix =np.linalg.inv(e),
2949
                                        width_px=math.ceil((i[0,2]+0.5)*2),
2950
                                        height_px=math.ceil((i[1,2]+0.5)*2))
2951
        
2952
        #apply scale
2953
        rays=rays.numpy()
×
2954
        rays=o3d.core.Tensor(rays[::int(1/scale),::int(1/scale)])
×
2955
        #cast rays
2956
        ans = scene.cast_rays(rays) 
×
2957
        
2958
        #get triangle_ids that are hit per ray
2959
        triangle_ids=ans['primitive_ids'].numpy() # triangles
×
2960
        rows,columns=triangle_ids.shape        
×
2961
        triangle_ids=triangle_ids.flatten()
×
2962
        # replace invalid id's by last (which is the above added black color)
2963
        np.put(triangle_ids,np.where(triangle_ids==scene.INVALID_ID),triangle_colors.shape[0]-1) 
×
2964
        
2965
        #select colors 
2966
        colors=triangle_colors[triangle_ids]
×
2967

2968
        #reshape array back to normal
2969
        colorImage=np.reshape(colors,(rows,columns,3))
×
2970
                
2971
        #fill black if necessary
2972
        colorImage=iu.fill_black_pixels(colorImage,fill_black)         if fill_black !=0       else colorImage
×
2973
        depthImage=iu.fill_black_pixels(ans['t_hit'].numpy(),fill_black)         if fill_black !=0       else ans['t_hit'].numpy()
×
2974

2975
        #add to list
2976
        colorImages.append(colorImage)    
×
2977
        depthImages.append(depthImage)
×
2978

2979
    return colorImages,depthImages
×
2980

2981
def rays_to_lineset(rays:np.ndarray,distances=None)->o3d.geometry.LineSet:
1✔
2982
    """Convert an array or o3d.tensor to a lineset that can be visualized in open3d.\n
2983
    
2984
    .. image:: ../../../docs/pics/Raycasting_3.PNG
2985

2986
    Args:
2987
        1.rays (np.array[n,6] or o3d.core.Tensor): ray consisting of a startpoint np.array[n,0:3] and a direction np.array[n,3:6]\n
2988
        2.distances (float or np.array[n],Optional): distance/distances over which to cast each ray. Defaults to 1.0m. 
2989

2990
    Returns:
2991
        o3d.geometry.LineSet
2992
    """
2993
    # Reshape rays and distances if necessary
2994
    rays = np.asarray(rays) if not isinstance(rays,np.ndarray) else rays
×
2995
    
2996
    if rays.ndim == 1 and distances is None:
×
2997
        distances=np.full((1,), 1)
×
2998
    elif rays.ndim == 2 and distances is None:
×
2999
        distances=np.full((1,1), 1)
×
3000
    elif rays.ndim == 2 and distances is not None:
×
3001
        distances=np.full((1,1), 1)
×
3002
    elif distances is not None and distances.size != rays.shape[0]:
×
3003
        raise ValueError("The size of distances array must match the number of rays")
×
3004

3005
    distances[distances == np.inf] = 50
×
3006
        
3007
    #get start and endpoints
3008
    startpoints, endpoints=rays_to_points(rays,distances)
×
3009
    points=np.vstack((startpoints,endpoints))
×
3010
    
3011
    #create lines
3012
    lines=[]
×
3013
    start=np.arange(start=0,stop=rays.shape[0] )[..., np.newaxis]
×
3014
    end=np.arange(start=rays.shape[0],stop=points.shape[0] )[..., np.newaxis]  
×
3015
    lines = np.hstack((start, end))
×
3016
    
3017
    #create lineset
3018
    line_set = o3d.geometry.LineSet()
×
3019
    # colors = [[1, 0, 0] for i in range(len(lines))]
3020
    line_set.points = o3d.utility.Vector3dVector(points)
×
3021
    line_set.lines = o3d.utility.Vector2iVector(lines)
×
3022
    return line_set
×
3023

3024
def rays_to_points(rays:np.ndarray,distances:np.ndarray=np.array([1.0])) -> Tuple[np.ndarray,np.ndarray]:
1✔
3025
    """Converts a set of rays to start-and endpoints.\n
3026

3027
    Args:
3028
        - rays (np.array[n,6] or o3d.core.Tensor): ray consisting of a startpoint np.array[n,0:3] and a direction np.array[n,3:6].\n
3029
        - distances (np.array[n], optional): scalar or array with distances of the ray. Defaults to 1.0m.\n
3030

3031
    Returns:
3032
        Tuple[np.array,np.array]: startpoints, endpoints
3033
    """
3034
    
3035
    # Validate inputs
3036
    if 'Tensor' in str(type(rays)):
×
3037
        rays = rays.numpy()
×
3038
    
3039
    # Reshape rays if necessary
3040
    rays = np.asarray(rays) if not isinstance(rays,np.ndarray) else rays
×
3041

3042
    if rays.ndim == 1:
×
3043
        assert rays.shape[0] == 6, f'rays.shape[0] should be 6, got {rays.shape[0]}.'
×
3044
        rays = np.reshape(rays, (1, 6))
×
3045
    elif rays.ndim == 2:
×
3046
        assert rays.shape[1] == 6, f'rays.shape[1] should be 6, got {rays.shape[1]}.'
×
3047
    else:
3048
        raise ValueError("Invalid rays shape. Expected shape (n, 6) or (6,).")
×
3049
    
3050
        
3051
    # Ensure distances is a 1D array with the correct size
3052
    distances=np.asarray(distances)if not isinstance(distances,np.ndarray) else distances
×
3053
    if distances.size != rays.shape[0]:
×
3054
        if distances.size == 1:
×
3055
            distances = np.full((rays.shape[0],), distances[0])
×
3056
        else:
3057
            raise ValueError("The size of distances array must match the number of rays")
×
3058

3059
    
3060
    #stack rays and distances
3061
    rays=np.hstack((rays,np.reshape(distances,(rays.shape[0],1))))
×
3062
    
3063
    #compute endpoints
3064
    def myfunction(x):
×
3065
        return np.array([x[0] + x[3]*x[-1],
×
3066
                         x[1] + x[4]*x[-1],
3067
                         x[2] + x[5]*x[-1]])
3068
    startpoints=rays[:,0:3]
×
3069
    endpoints=np.apply_along_axis(myfunction, axis=1, arr=rays)    
×
3070
    
3071
    return startpoints, endpoints
×
3072

3073
    
3074

3075
def sample_geometry(geometries:List[o3d.geometry.Geometry],resolution:float=0.1)->List[o3d.geometry.PointCloud]:
1✔
3076
    """Sample the surface, line or point cloud of an open3d object given a resolution.
3077

3078
    Args:
3079
        geometries (List[o3d.geometry.Geometry]): o3d.Geometry.LineSet,o3d.Geometry.TriangleMesh or o3d.Geometry.PointCloud
3080
        resolution (float, optional): spacing between sampled points. Defaults to 0.1m.
3081

3082
    Returns:
3083
        List[o3d.geometry.PointCloud]
3084
    """
3085
    isList = isinstance(geometries, List) # if the input is a list, always return a list
1✔
3086
    geometries=ut.item_to_list(geometries)
1✔
3087
        
3088
    point_clouds=[]
1✔
3089
    for g in geometries:
1✔
3090
        pcd=o3d.geometry.PointCloud()
1✔
3091
        
3092
        if 'TriangleMesh' in str(type(g)) and len(g.vertices) != 0:
1✔
3093
            area=g.get_surface_area()
1✔
3094
            count=int(area/(resolution*resolution))
1✔
3095
            pcd=g.sample_points_uniformly(number_of_points=count)
1✔
3096
            
3097
        elif 'PointCloud' in str(type(g)) and len(g.points) != 0: 
×
3098
            pcd=g.voxel_down_sample(resolution)
×
3099
            
3100
        elif 'ndarray' in str(type(g)):
×
3101
            pcd.points=o3d.utility.Vector3dVector(g)
×
3102
            pcd=pcd.voxel_down_sample(resolution)
×
3103
            
3104
        elif 'LineSet' in str(type(g)):    
×
3105
            # Get line segments from the LineSet
3106
            pointArray=np.asarray(g.points)
×
3107
            points = []
×
3108

3109
            for line in np.asarray(g.lines): #! this is not efficient
×
3110
                #get start and end
3111
                start_point = pointArray[line[0]]
×
3112
                end_point = pointArray[line[1]]
×
3113
                #get direction and length
3114
                direction = end_point - start_point
×
3115
                length = np.linalg.norm(direction)
×
3116
                #compute number of points
3117
                num_points = int(length / resolution)
×
3118
                if num_points > 0:
×
3119
                    step = direction / num_points
×
3120
                    p=[start_point + r * step for r in range(num_points + 1)]
×
3121
                    points.extend(p)
×
3122
            pcd.points=o3d.utility.Vector3dVector(points)  
×
3123
                  
3124
        point_clouds.append(pcd)
1✔
3125
        
3126
    return point_clouds if len(point_clouds)>1 or isList else point_clouds[0]
1✔
3127

3128
def save_dataframe_as_ply(filename, points=None, mesh=None, as_text=False, comments=None):
1✔
3129
    """Write a PLY file populated with the given fields.\n
3130
    
3131
    Args:
3132
        1. filename (str) :The created file will be named with this\n
3133
        2. points (ndarray): \n
3134
        3. mesh (ndarray): \n
3135
        4. as_text (bool): Set the write mode of the file. Defaults to binary.\n
3136
        5. comments: list of string\n
3137
        
3138
    Returns
3139
        bool: True if no problems
3140
    """
3141
    if not filename.endswith('ply'):
×
3142
        filename += '.ply'
×
3143
    # open in text mode to write the header
3144
    with open(filename, 'w') as ply:
×
3145
        header = ['ply']
×
3146
        if as_text:
×
3147
            header.append('format ascii 1.0')
×
3148
        else:
3149
            header.append('format binary_' + sys.byteorder + '_endian 1.0')
×
3150
            
3151
        if comments:
×
3152
            for comment in comments:
×
3153
                header.append('comment ' + comment)
×
3154

3155
        if points is not None:
×
3156
            header.extend(describe_element('vertex', points))
×
3157
        if mesh is not None:
×
3158
            mesh = mesh.copy()
×
3159
            mesh.insert(loc=0, column="n_points", value=3)
×
3160
            mesh["n_points"] = mesh["n_points"].astype("u1")
×
3161
            header.extend(describe_element('face', mesh))
×
3162
        header.append('end_header')
×
3163
        for line in header:
×
3164
            ply.write("%s\n" % line)
×
3165
    if as_text:
×
3166
        if points is not None:
×
3167
            points.to_csv(filename, sep=" ", index=False, header=False, mode='a',
×
3168
                          encoding='ascii')
3169
        if mesh is not None:
×
3170
            mesh.to_csv(filename, sep=" ", index=False, header=False, mode='a',
×
3171
                        encoding='ascii')
3172
    else:
3173
        with open(filename, 'ab') as ply:
×
3174
            if points is not None:
×
3175
                points.to_records(index=False).tofile(ply)
×
3176
            if mesh is not None:
×
3177
                mesh.to_records(index=False).tofile(ply)
×
3178
    return True
×
3179

3180
def save_view_point(geometry: o3d.geometry, filename:str) -> None:
1✔
3181
    """Saves the viewpoint of a set of geometry in the given filename.\n
3182
    
3183
    **NOTE**: this is very inefficient -> join geometries first
3184

3185
    Args:
3186
        1. geometry (o3d.geometry): geometries to visualize.\n
3187
        2. filename (str): absolute filepath\n
3188
    """
3189
    vis = o3d.visualization.Visualizer()
×
3190
    vis.create_window()
×
3191
    vis.add_geometry(geometry)
×
3192
    vis.run()  # user changes the view and press "q" to terminate
×
3193
    param = vis.get_view_control().convert_to_pinhole_camera_parameters()
×
3194
    o3d.io.write_pinhole_camera_parameters(filename, param)
×
3195
    vis.destroy_window()
×
3196

3197
def segment_pcd_by_connected_component(pcd:o3d.geometry.PointCloud, eps:float=0.03, minPoints:int=10,printProgress:bool=False) -> List[o3d.geometry.PointCloud]:
1✔
3198
    """Returns list of point clouds segmented by db_cluster DBSCAN algorithm Ester et al., ‘A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise’, 1996.\n
3199

3200
    Args:
3201
        1. pcd (o3d.geometry.PointCloud) \n
3202
        2. eps (float, optional): Density parameter that is used to find neighbouring points. Defaults to 0.03m.\n
3203
        3. minPoints (int, optional): Minimum number of points to form a cluster. Defaults to 10.\n
3204
        4. printProgress (bool)
3205

3206
    Raises:
3207
        ValueError: len(pcd.points)<minPoints
3208

3209
    Returns:
3210
        List[o3d.geometry.PointCloud]
3211
    """
3212
    # validate point cloud    
3213
    assert len(pcd.points)<minPoints, f'len(pcd.points)<minPoints'
×
3214
    
3215
    pcds=[]
×
3216
    with o3d.utility.VerbosityContextManager(o3d.utility.VerbosityLevel.Debug) as cm:
×
3217
        labels = np.array(pcd.cluster_dbscan(eps=eps, min_points=minPoints, print_progress=printProgress))
×
3218

3219
    labelList=np.unique(labels)    
×
3220
    for l in range(0,labelList[-1]):
×
3221
        indices = np.where(labels == l)[0]
×
3222
        pcds.append(pcd.select_by_index(indices))
×
3223
    return ut.item_to_list(pcds)
×
3224
def show_geometries(geometries : 'List[o3d.geometry]', color : bool = False):
1✔
3225
    """Displays different types of geometry in a scene
3226

3227
    **NOTE**: this is very inefficient -> join geometries first
3228

3229
    Args:
3230
        geometries (List[open3d.geometry]): The list of geometries
3231
        color (bool, optional): recolor the objects to have a unique color. Defaults to False.
3232
    """
3233
    viewer = o3d.visualization.Visualizer()
×
3234
    viewer.create_window()
×
3235
    frame = o3d.geometry.TriangleMesh.create_coordinate_frame()
×
3236
    viewer.add_geometry(frame)
×
3237
    for i, geometry in enumerate(geometries):
×
3238
        if color:
×
3239
            geometry.paint_uniform_color(ut.get_random_color())
×
3240
            # geometry.paint_uniform_color(matplotlib.colors.hsv_to_rgb([float(i)/len(geometries),0.8,0.8]))
3241
        viewer.add_geometry(geometry)
×
3242
    opt = viewer.get_render_option()
×
3243
    opt.background_color = np.asarray([1,1,1])
×
3244
    opt.light_on = True
×
3245
    viewer.run()
×
3246

3247
def split_pcd_by_labels(point_cloud:o3d.geometry.PointCloud,labels:np.ndarray)-> Tuple[List[o3d.geometry.PointCloud],np.ndarray]:
1✔
3248
    """Split a point cloud in parts to match a list of labels. The result is a set of point clouds, one for each unique label.
3249

3250
    Args:
3251
        point_cloud (o3d.geometry.PointCloud):
3252
        labels (np.ndarray): integer array with the same length as the point clouds 
3253

3254
    Returns:
3255
        Tuple[List[o3d.geometry.PointCloud],np.ndarray]: point clouds, unique labels
3256
    """
3257
    pcdList=[]
×
3258
    unique=np.unique(labels)
×
3259
    for i in unique:    
×
3260
        ind = np.where(labels==i)[0]
×
3261
        pcdList.append(point_cloud.select_by_index(ind))
×
3262
    return pcdList,unique
×
3263

3264
def split_quad_faces(faces:np.ndarray) ->np.ndarray:
1✔
3265
    """Split an array of quad faces e.g. [[0,1,2,3]] into triangle faces e.g. [[0,1,2],[0,2,3]]
3266

3267
    Args:
3268
        faces (np.ndarray[nx4]) 
3269

3270
    Returns:
3271
        np.ndarray[2nx3]
3272
    """
3273
    newFaces=np.zeros((1,3),dtype=np.uint16)
×
3274
    for f in faces:
×
3275
        f=np.array(f)       
×
3276
        newFaces=np.vstack((newFaces,np.array([f[0:3]]),np.array([f[0],f[2],f[3]]))) if f.shape[0]==4 else np.vstack((newFaces,f))
×
3277
    newFaces=np.delete(newFaces,0,axis=0)
×
3278
        
3279
    return newFaces
×
3280

3281
def transform_dataframe(df:pd.DataFrame,transform:np.array,pointFields:List[str]=['x', 'y', 'z','Nx', 'Ny', 'Nz'])->pd.DataFrame:
1✔
3282
    """apply rigid body transformation to the 3D point coordinates[x,y,z] in a pandas dataFrame.\n
3283

3284
    Args:
3285
        1. df (pd.DataFrame)\n
3286
        2. transform (np.array[4x4]): Rigid body transformation.\n
3287
        3. pointFields (List[str], optional): names of the dataFrame columns. Defaults to ['x', 'y', 'z','Nx', 'Ny', 'Nz'].\n
3288

3289
    Raises:
3290
        ValueError: 'No valid xyz data. Make sure column headers are names X,Y,Z'
3291

3292
    Returns:
3293
        pd.DataFrame
3294
    """
3295
    if transform is not None:
×
3296
        assert transform.shape[0]==4
×
3297
        assert transform.shape[1]==4
×
3298

3299
    #validate pointfields    
3300
    if pointFields == None:
×
3301
        pointFields=['x', 'y', 'z','Nx', 'Ny', 'Nz']
×
3302
    fields=[s.casefold() for s in pointFields]
×
3303

3304
    #transform XYZ
3305
    if (all(elem.casefold() in fields for elem in ['X', 'Y', 'Z'])):
×
3306
        xyz=df.get([pointFields[0], pointFields[1], pointFields[2]])
×
3307
        newxyz=transform_points( xyz.to_numpy(),transform) if transform is not None else xyz.to_numpy()
×
3308
        #replace column
3309
        for i,n in enumerate([pointFields[0], pointFields[1], pointFields[2]]):
×
3310
            df[n] = newxyz[:,i].tolist()
×
3311

3312
            # df.drop(n, axis = 1, inplace = True)
3313
            # df[n] = newxyz[:,i].tolist()
3314
    else:
3315
        raise ValueError('No valid xyz data. Make sure column headers are names X,Y,Z')
×
3316

3317
    #transform ['Nx', 'Ny', 'Nz']
3318
    if (all(elem.casefold() in pointFields for elem in ['Nx', 'Ny', 'Nz'])): 
×
3319
        nxyz=df.get(['Nx', 'Ny', 'Nz'])
×
3320
        newnxyz=transform_points( nxyz.to_numpy(),transform) if transform is not None else nxyz.to_numpy()
×
3321
        #replace column
3322
        for i,n in enumerate(['Nx', 'Ny', 'Nz']):
×
3323
            df.drop(n, axis = 1, inplace = True)
×
3324
            df[n] = newnxyz[:,i].tolist()
×
3325
    return df
×
3326

3327
def transform_points(points:np.ndarray,transform:np.ndarray)->np.ndarray:
1✔
3328
    """Transform points with transformation matrix.\n
3329

3330
    Args:
3331
        1.points (np.array(:,3)): points to transform\n
3332
        2.transform (np.array(4,4)): transformation Matrix\n
3333

3334
    Returns:
3335
        np.array(:,3)
3336
    """
3337
    assert(points.shape[1] == 3)
×
3338
    assert(transform.shape[0] == 4)
×
3339
    assert(transform.shape[1]== 4)
×
3340

3341
    hpoints=np.hstack((points,np.ones((points.shape[0],1))))
×
3342
    hpoints=transform @ hpoints.transpose()
×
3343
    return hpoints[0:3].transpose()
×
3344

3345
def voxelgrid_to_mesh(voxel_grid:o3d.geometry.VoxelGrid,voxel_size:float=0.4,colorArray:np.array=None)-> o3d.geometry.TriangleMesh:
1✔
3346
    """Create a TriangleMesh from a voxelGrid.
3347
    
3348
    .. image:: ../../../docs/pics/pcd2.PNG
3349
    .. image:: ../../../docs/pics/voxelgrid_1.PNG
3350
    
3351
    Args:
3352
        voxel_grid (o3d.geometry.VoxelGrid):
3353
        voxel_size (float, optional): size of each voxel. Defaults to 0.4.
3354
        colorArray (np.array,optional): optional colorArray np.Array(len(voxels),3) from [0-1]
3355

3356
    Returns:
3357
        o3d.geometry.TriangleMesh
3358
    """
3359
    # get all voxels in the voxel grid
3360
    voxels_all= voxel_grid.get_voxels()
×
3361
    # geth the calculated size of a voxel
3362
    voxel_size = voxel_grid.voxel_size
×
3363
    # loop through all the voxels
3364
    cubes=[]
×
3365
    for i,voxel in enumerate(voxels_all):
×
3366
        # create a cube mesh with a size 1x1x1
3367
        cube=o3d.geometry.TriangleMesh.create_box(width=1, height=1, depth=1)
×
3368
        # paint it with the color of the current voxel
3369
        cube.paint_uniform_color(voxel.color) if colorArray is None else cube.paint_uniform_color(colorArray[i])
×
3370
        # scale the box using the size of the voxel
3371
        cube.scale(voxel_size, center=cube.get_center())
×
3372
        # get the center of the current voxel
3373
        voxel_center = voxel_grid.get_voxel_center_coordinate(voxel.grid_index)
×
3374
        # translate the box to the center of the voxel
3375
        cube.translate(voxel_center, relative=False)
×
3376
        # add the box to the TriangleMesh object
3377
        cubes.append(cube)
×
3378
    return join_geometries(cubes)
×
3379

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