import os
import numpy as np
import pandas as pd
from .structures.base import StructuresDict
from .filters import ALL_FILTERS
from .io import FROM_FILE, TO_FILE, FROM_INSTANCE, TO_INSTANCE
from .neighbors import k_neighbors, r_neighbors
from .plot import DESCRIPTION, AVAILABLE_BACKENDS
from .plot.matplotlib_backend import plot_with_matplotlib
from .plot.threejs_backend import plot_with_threejs
from .plot.pythreejs_backend import plot_with_pythreejs
from .plot.pyvista_backend import plot_with_pyvista
from .samplers import ALL_SAMPLERS
from .scalar_fields import ALL_SF
from .structures import ALL_STRUCTURES
from .utils.dataframe import convert_columns_dtype
class PyntCloud(object):
"""A Pythonic Point Cloud."""
def __init__(self, points, mesh=None, structures=None, **kwargs):
"""Create PyntCloud.
Parameters
----------
points: pd.DataFrame
DataFrame of N rows by M columns.
Each row represents one point of the point cloud.
Each column represents one scalar field associated to its corresponding point.
mesh: pd.DataFrame or None, optional
Default: None
Triangular mesh associated with points.
structures: dict, optional
Map key(base.Structure.id) to val(base.Structure)
kwargs: custom attributes
"""
self.points = points
self.mesh = mesh
self.structures = StructuresDict()
structures = structures or {}
for key, val in structures.items():
self.structures[key] = val
for key, val in kwargs.items():
setattr(self, key, val)
# store raw xyz values to share memory along structures
self.xyz = self.points[["x", "y", "z"]].values
self.centroid = self.xyz.mean(0)
def __repr__(self):
default = [
"_PyntCloud__points",
"_PyntCloud__mesh",
"structures",
"xyz",
"centroid"
]
others = ["\n\t {}: {}".format(x, str(type(getattr(self, x))))
for x in self.__dict__ if x not in default]
if self.mesh is None:
n_faces = 0
else:
n_faces = len(self.mesh)
return DESCRIPTION.format(
len(self.points), len(self.points.columns) - 3,
n_faces,
self.structures.n_kdtrees,
self.structures.n_voxelgrids,
self.centroid[0], self.centroid[1], self.centroid[2],
"".join(others))
@property
def points(self):
return self.__points
@points.setter
def points(self, df):
if not isinstance(df, pd.DataFrame):
raise TypeError("Points argument must be a DataFrame")
elif not set(['x', 'y', 'z']).issubset(df.columns):
raise ValueError("Points must have x, y and z coordinates")
self._update_points(df)
@property
def mesh(self):
return self.__mesh
@mesh.setter
def mesh(self, df):
# allow PyntCloud to don't have mesh assigned
if df is not None:
if not isinstance(df, pd.DataFrame):
raise TypeError("Mesh argument must be a DataFrame")
elif not set(['v1', 'v2', 'v3']).issubset(df.columns):
print(df.columns)
raise ValueError(
"Mesh must have v1, v2 and v3 columns, at least")
self.__mesh = df
else:
self.__mesh = None
[docs] @classmethod
def from_file(cls, filename, **kwargs):
"""Extract data from file and construct a PyntCloud with it.
Parameters
----------
filename: str
Path to the file from which the data will be read
kwargs: only usable in some formats
Returns
-------
PyntCloud: object
PyntCloud instance, containing all valid elements in the file.
"""
ext = filename.split(".")[-1].upper()
if ext not in FROM_FILE:
raise ValueError(
"Unsupported file format; supported formats are: {}".format(list(FROM_FILE)))
else:
return cls(**FROM_FILE[ext](filename, **kwargs))
@classmethod
def from_instance(cls, library, instance, **kwargs):
"""Convert library's instance to PyntCloud intstance.
Parameters
----------
library: str
Name of the library of the instance to be converted from.
instance:
`library's` instance
kwargs: only usable in some formats
Returns
-------
PyntCloud: object
PyntCloud instance, containing all valid elements in the file.
"""
library = library.upper()
if library not in FROM_INSTANCE:
raise ValueError(
"Unsupported library; supported libraries are: {}".format(list(FROM_INSTANCE)))
else:
return cls(**FROM_INSTANCE[library](instance, **kwargs))
[docs] def to_file(self, filename, also_save=None, **kwargs):
"""Save PyntCloud data to file.
Parameters
----------
filename: str
Path to the file from which the data will be read
also_save: list of str, optional
Default: None
Names of the attributes that will be extracted from the PyntCloud
to be saved in addition to points. Usually also_save=["mesh"]
kwargs: only usable in some formats
"""
convert_columns_dtype(self.points, np.float64, np.float32)
ext = filename.split(".")[-1].upper()
if ext not in TO_FILE:
raise ValueError(
"Unsupported file format; supported formats are: {}".format(list(TO_FILE)))
kwargs["filename"] = filename
kwargs["points"] = self.points
if also_save is not None:
for x in also_save:
kwargs[x] = getattr(self, x)
TO_FILE[ext](**kwargs)
def to_instance(self, library, **kwargs):
"""Convert PyntCloud's instance to library's instance.
Parameters
----------
library: str
Name of the library of the instance to be converted to.
kwargs: only usable in some formats
"""
convert_columns_dtype(self.points, np.float64, np.float32)
library = library.upper()
if library not in TO_INSTANCE:
raise ValueError(
"Unsupported library; supported libraries are: {}".format(list(TO_INSTANCE)))
return TO_INSTANCE[library](self, **kwargs)
[docs] def add_scalar_field(self, name, **kwargs):
"""Add one or multiple columns to PyntCloud.points.
Parameters
----------
name: str
One of the available names. See below.
kwargs
Vary for each name. See below.
Returns
-------
sf_added: list of str
The name of each of the columns (scalar fields) added.
Useful for chaining operations that require string names.
Notes
-----
Available scalar fields are:
**REQUIRE EIGENVALUES**
ARGS
ev: list of str
ev = self.add_scalar_field("eigen_values", ...)
sphericity
anisotropy
linearity
omnivariance
eigenentropy
planarity
eigen_sum
curvature
**REQUIRE K_NEIGHBORS**
ARGS
k_neighbors: (N, k) ndarray
Returned from: self.get_neighbors(k, ...) /
manually querying some self.kdtrees[x] /
other methods.
eigen_decomposition
eigen_values
**REQUIRE NORMALS**
orientation_degrees
orientation_radians
inclination_radians
inclination_degrees
**REQUIRE RGB**
hsv
relative_luminance
rgb_intensity
**REQUIRE VOXELGRID**
ARGS
voxelgrid_id: VoxelGrid.id
voxelgrid_id = self.add_structure("voxelgrid", ...)
voxel_x
voxel_y
voxel_n
voxel_z
euclidean_clusters
**ONLY REQUIRE XYZ**
plane_fit
max_dist: float, optional
Default: 1e-4
Maximum distance from point to model in order to be considered as inlier.
max_iterations: int, optional (Default 100)
Maximum number of fitting iterations.
sphere_fit
max_dist: float, optional
Default: 1e-4
Maximum distance from point to model in order to be considered as inlier.
max_iterations: int, optional
Default: 100
Maximum number of fitting iterations.
custom_fit
model: subclass of ransac.models.RansacModel
Model to be fitted
sampler: subclass of ransac.models.RansacSampler
Sample method to be used
name: str
Will be used to name the added column
model_kwargs: dict, optional
Default: {}
Will be passed to single_fit function.
sampler_kwargs: dict, optional
Default: {}
Will be passed to single_fit function.
spherical_coords
degrees: bool, optional
Default: True.
Return polar and azimuthal angles in degrees.
"""
if name in ALL_SF:
scalar_field = ALL_SF[name](pyntcloud=self, **kwargs)
scalar_field.extract_info()
scalar_field.compute()
scalar_fields_added = scalar_field.get_and_set()
else:
raise ValueError("Unsupported scalar field. Check docstring")
return scalar_fields_added
[docs] def add_structure(self, name, **kwargs):
"""Build a structure and add it to the corresponding PyntCloud's attribute.
Parameters
----------
name: str
One of the available names. See below.
kwargs
Vary for each name. See below.
Returns
-------
structure.id: str
Identification string associated with the added structure.
Useful for chaining operations that require string names.
Notes
-----
Available structures are:
**ONLY REQUIRE XYZ**
kdtree
leafsize: int, optional
Default: 16
The number of points at which the algorithm switches over to brute-force.
Has to be positive.
voxelgrid
x_y_z: list of int, optional
Default: [2, 2, 2]
The number of segments in which each axis will be divided.
x_y_z[0]: x axis
x_y_z[1]: y axis
x_y_z[2]: z axis
If sizes is not None it will be ignored.
sizes: list of float, optional
Default: None
The desired voxel size along each axis.
sizes[0]: voxel size along x axis.
sizes[1]: voxel size along y axis.
sizes[2]: voxel size along z axis.
bb_cuboid: bool, optional
Default: True
If True, the bounding box of the point cloud will be adjusted
in order to have all the dimensions of equal length.
octree
TODO
"""
if name in ALL_STRUCTURES:
info = ALL_STRUCTURES[name].extract_info(pyntcloud=self)
structure = ALL_STRUCTURES[name](**info, **kwargs)
structure.compute()
structure_added = structure.get_and_set(self)
else:
raise ValueError("Unsupported structure. Check docstring")
return structure_added
[docs] def get_filter(self, name, and_apply=False, **kwargs):
"""Compute filter over PyntCloud's points and return it.
Parameters
----------
name: str
One of the available names. See below.
and_apply: boolean, optional
Default: False
If True, filter will be applied to self.points
kwargs
Vary for each name. See below.
Returns
-------
filter: boolean array
Boolean mask indicating wherever a point should be keeped or not.
The size of the boolean mask will be the same as the number of points
in the pyntcloud.
Notes
-----
Available filters are:
**REQUIRE KDTREE**
ARGS
kdtree : KDTree.id
kdtree = self.add_structure("kdtree", ...)
ROR (Radius Outlier Removal)
k: int
Number of neighbors that will be used to compute the filter.
r: float
The radius of the sphere with center on each point. The filter
will look for the required number of neighboors inside that sphere.
SOR (Statistical Outlier Removal)
k: int
Number of neighbors that will be used to compute the filter.
z_max: float
The maximum Z score which determines if the point is an outlier.
**ONLY REQUIRE XYZ**
BBOX (Bounding Box)
min_i, max_i: float
The bounding box limits for each coordinate. If some limits are missing,
the default values are -infinite for the min_i and infinite for the max_i.
"""
if name in ALL_FILTERS:
pointcloud_filter = ALL_FILTERS[name](pyntcloud=self, **kwargs)
pointcloud_filter.extract_info()
boolean_array = pointcloud_filter.compute()
if and_apply:
self.apply_filter(boolean_array)
return boolean_array
else:
raise ValueError("Unsupported filter. Check docstring")
[docs] def get_sample(self, name, as_PyntCloud=False, **kwargs):
"""Return arbitrary number of points sampled by selected method.
Parameters
----------
name: str
One of the available names.
See below.
as_PyntCloud: bool, optional
Default: False
If True, sampled points will be returned as PyntCloud instance.
kwargs
Vary for each name.
See below.
Returns
-------
sampled_points: (n, 3) ndarray or PyntCloud
'n' vary for each method. PyntCloud if as_PyntCloud
Notes
-----
Available sampling methods are:
**REQUIRE MESH**
mesh_random
n: int
Number of points to be sampled.
rgb: bool, optional
Default: False
Indicates if rgb values will be also sampled
normals: bool, optional
Default: False
Indicates if normals will be also sampled
**REQUIRE VOXELGRID**
ARGS
voxelgrid: VoxelGrid.id
voxelgrid = self.add_structure("voxelgrid", ...)
voxelgrid_centers
voxelgrid_centroids
voxelgrid_nearest
n: int
Number of nearest point per voxel to sample
voxelgrid_highest
**USE POINTS**
points_random
n: int
Number of points to be sampled.
"""
if name in ALL_SAMPLERS:
sampler = ALL_SAMPLERS[name](pyntcloud=self, **kwargs)
sampler.extract_info()
sample = sampler.compute()
if as_PyntCloud:
return PyntCloud(sample)
return sample
else:
raise ValueError("Unsupported sampling method. Check docstring")
[docs] def get_neighbors(self, k=None, r=None, kdtree=None):
"""For each point finds the indices that compose its neighborhood.
Parameters
----------
k: int, optional
Default: None
For "K-nearest neighbor" search.
Number of nearest neighbors that will be used to build the neighborhood.
r: float, optional
Default: None
For "Fixed-radius neighbors" search.
Radius of the sphere that will be used to build the neighborhood.
kdtree: str, optional
Default: None
KDTree.id in self.structures.
- If **kdtree** is None:
The KDTree will be computed and added to PyntCloud as part of the process.
- Else:
The given KDTree will be used for neighbor search.
Returns
-------
neighbors: array-like
(N, k) ndarray if k is not None.
Indices of the 'k' nearest neighbors for the 'N' points.
(N,) ndarray of lists if r is not None.
Array holding a variable number of indices corresponding
to the neighbors with distance < r.
"""
if kdtree is None:
kdtree_id = self.add_structure("kdtree")
kdtree = self.structures[kdtree_id]
else:
kdtree = self.structures[kdtree]
if k is not None:
return k_neighbors(kdtree, k)
elif r is not None:
return r_neighbors(kdtree, r)
else:
raise ValueError("You must supply 'k' or 'r' values.")
[docs] def get_mesh_vertices(self, rgb=False, normals=False):
"""Decompose triangles of self.mesh from vertices in self.points.
Returns
-------
v1, v2, v3: ndarray
(N, 3) arrays of vertices so v1[i], v2[i], v3[i] represent the ith triangle
"""
use_columns = ["x", "y", "z"]
if rgb:
use_columns.extend(["red", "green", "blue"])
if normals:
use_columns.extend(["nx", "ny", "nz"])
points = self.points[use_columns].values
v1 = points[self.mesh["v1"].values]
v2 = points[self.mesh["v2"].values]
v3 = points[self.mesh["v3"].values]
return v1, v2, v3
[docs] def apply_filter(self, boolean_array):
"""Update self.points removing points where filter is False.
Parameters
----------
boolean_array: ndarray, dtype bool
len(boolean array) must be equal to len(self.points)
"""
self.points = self.points.loc[boolean_array].reset_index(drop=True)
[docs] def split_on(self, scalar_field, and_return=False, save_format="ply", save_path=os.getcwd()):
"""Divide the PyntCloud using unique values in given sf.
This function will generate PyntClouds by grouping points using the unique
values found in the given scalar field.
Parameters
----------
scalar_field: str
Name of the scalar field to be used for splitting.
and_return: boolean, optional
Default: False
If True, return a list with the splits.
save_format: str, optional
Default: "ply"
Extension used to save the generated PyntClouds.
Must be of one of the formats present in pyntcloud.io.TO
save_path: str, optional
Default: "."
Path where the PyntClouds will be saved.
"""
scalar_field = self.points[scalar_field]
splits = {x: PyntCloud(self.points.loc[scalar_field == x]) for x in scalar_field.unique()}
if not os.path.exists(save_path):
os.makedirs(save_path)
for key, val in splits.items():
val.to_file("{}/{}.{}".format(save_path, key, save_format))
if and_return:
return splits
def _update_points(self, df):
"""Utility function. Implicitly called when self.points is assigned."""
self.mesh = None
self.structures = StructuresDict()
self.__points = df
self.xyz = self.__points[["x", "y", "z"]].values
self.centroid = self.xyz.mean(0)
[docs] def plot(
self,
backend=None,
scene=None,
width=800,
height=500,
background="black",
mesh=False,
use_as_color=["red", "green", "blue"],
initial_point_size=None,
cmap="hsv",
polylines=None,
linewidth=5,
return_scene=False,
output_name="pyntcloud_plot",
elev=0.,
azim=90.,
**kwargs
):
"""Visualize a PyntCloud using different backends.
Parameters
----------
backend: {"pythreejs", "threejs", "pyvista", "matplotlib"}, optional
Default: "pythreejs"
Used to select one of the available libraries for plotting.
width: int, optional
Default: 800
height: int, optional
Default: 500
background: str, optional
Default: "black"
Used to select the default color of the background.
In some backends, i.e "pythreejs" the background can be dynamically changed.
use_as_color: str or ["red", "green", "blue"], optional
Default: ["red", "green", "blue"]
Indicates which scalar fields will be used to colorize the rendered
point cloud.
initial_point_size: the initial size of each point in the rendered point cloud.
Can be adjusted after rendering using slider.
cmap: str, optional
Default: "hsv"
Color map that will be used to convert a single scalar field into rgb.
Check matplotlib cmaps.
return_scene: bool, optional
Default: False.
Used with "pythreejs" backend in order to return the pythreejs.Scene object
polylines: list of dict, optional
Default None.
List of dictionaries defining polylines with the following syntax:
polylines=[
{
"color": "0xFFFFFF",
"vertices": [[0, 0, 0], [0, 0, 1]]
},
{ {
"color": "red",
"vertices": [[0, 0, 0], [0, 0, 1], [0, 2, 0]
}
]
elev: float
Elevation angle in the z plane. Used for matplotlib
azim: float
Azimuth angle in the x,y plane.
"""
args = locals()
args.update(kwargs)
backend = args.pop("backend")
# Choose fisrt avaialable backend
if backend is None and len(AVAILABLE_BACKENDS) > 0:
backend = AVAILABLE_BACKENDS[0]
elif backend is None:
backend = 'pythreejs'
# Plot with backend of choice
if backend == "matplotlib":
return plot_with_matplotlib(self, **args)
elif backend == "pythreejs":
return plot_with_pythreejs(self, **args)
elif backend == "threejs":
return plot_with_threejs(self, **args)
elif backend == "pyvista":
return plot_with_pyvista(self, **args)
else:
raise NotImplementedError("{} backend is not supported".format(backend))