In [1]:
NAME = "Victor Rong"
TITLE = "Surface Reconstruction Using Boundary Integral Equations"
In [2]:
# All imports

import os
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
os.environ["OMP_NUM_THREADS"] = "1"

import numpy as np
np.random.seed(1)

import open3d as o3d
import time
from scipy.special import sph_harm

from matplotlib import pyplot as plt
# plt.rcParams.update({'font.size': 24})

from skimage import measure
from sklearn.neighbors import NearestNeighbors
Jupyter environment detected. Enabling Open3D WebVisualizer.
[Open3D INFO] WebRTC GUI backend enabled.
[Open3D INFO] WebRTCWindowSystem: HTTP handshake server disabled.
In [3]:
class BoundingBox():
    """
    Simple axis-aligned bounding box class
    """
    def __init__(self, corners, boundary="[][][]"):
        """
        Initializes bounding box
        Corners is list of length 2 with numpy arrays of shape (3,)
        Boundary is denoting closed/open intervals in each axis although it doesn't end up being relevant
        """
        self.corners = corners
        self.boundary = boundary
    
    def width(self):
        """
        Returns real number, the length of the longest side
        """
        return np.amax(self.corners[1]-self.corners[0])
    
    def center(self):
        """
        Returns numpy array of shape (3,)
        """
        return 0.5*(self.corners[0]+self.corners[1])
    
    def meshgrid(self, x, y, z):
        """
        x, y, z are integers
        Returns coordinates forming regular grid on bounding box where x, y, z are the number of cells in each axis
        Coordinates xyz is a numpy array of shape (x, y, z, 3)
        """
        xi, yi, zi = np.meshgrid(np.linspace(self.corners[0][0], self.corners[1][0], x), np.linspace(self.corners[0][1], self.corners[1][1], y), np.linspace(self.corners[0][1], self.corners[1][1], z))
        xyz = np.concatenate([np.expand_dims(xi, -1), np.expand_dims(yi, -1), np.expand_dims(zi, -1)], axis=-1)
        return xyz

    def halve(self, axis):
        """
        Subdivides bounding box into two along axis
        """
        left_corner = np.array(self.corners[1])
        left_corner[axis] = (self.corners[0][axis] + self.corners[1][axis]) / 2
        left_boundary = list(self.boundary)
        left_boundary[2*axis+1] = ")"
        left_boundary = "".join(left_boundary)
        left_bbox = BoundingBox([self.corners[0], left_corner], boundary=left_boundary)

        right_corner = np.array(self.corners[0])
        right_corner[axis] = (self.corners[0][axis] + self.corners[1][axis]) / 2
        right_boundary = list(self.boundary)
        right_boundary[2*axis+0] = "["
        right_boundary = "".join(right_boundary)
        right_bbox = BoundingBox([right_corner, self.corners[1]], boundary=right_boundary)
        return left_bbox, right_bbox
    
    def get_filter(self, pcd):
        """
        Returns numpy boolean array representing mask on points
        """
        points = np.array(pcd.points)
        lower_mask = np.ones(points.shape[0], dtype=bool)
        for i in range(3):
            if self.boundary[2*i+0] == "(":
                lower_mask = lower_mask & (points[:,i] > self.corners[0][i])
            else:
                lower_mask = lower_mask & (points[:,i] >= self.corners[0][i])
        upper_mask = np.ones(points.shape[0], dtype=bool)
        for i in range(3):
            if self.boundary[2*i+1] == ")":
                upper_mask = upper_mask & (points[:,i] < self.corners[1][i])
            else:
                upper_mask = upper_mask & (points[:,i] <= self.corners[1][i])
        return lower_mask & upper_mask

    def filter_point_cloud(self, pcd):
        """
        Returns a new point cloud formed by only taking points in the bounding box
        """
        points = np.array(pcd.points)
        normals = np.array(pcd.normals)
        groups = np.array(pcd.groups)
        ids = np.array(pcd.ids)
        lower_mask = np.ones(points.shape[0], dtype=bool)
        for i in range(3):
            if self.boundary[2*i+0] == "(":
                lower_mask = lower_mask & (points[:,i] > self.corners[0][i])
            else:
                lower_mask = lower_mask & (points[:,i] >= self.corners[0][i])
        upper_mask = np.ones(points.shape[0], dtype=bool)
        for i in range(3):
            if self.boundary[2*i+1] == ")":
                upper_mask = upper_mask & (points[:,i] < self.corners[1][i])
            else:
                upper_mask = upper_mask & (points[:,i] <= self.corners[1][i])
        return PointCloud(points[lower_mask & upper_mask], normals[lower_mask & upper_mask], groups[lower_mask & upper_mask], ids[lower_mask & upper_mask])

class PointCloud():
    """
    Simple point cloud class
    """
    def __init__(self, points, normals, groups=None, ids=None):
        """
        Initializes point cloud with oriented normals
        Points and normals should be numpy arrays of shape (n, 3)
        Normals doesn't actually have to be normals, it's fine to set it to np.zeros_like(points) if they're not available
        Groups and ids are metadata for each point used in our Barnes-Hut implementation
        """
        self.points = points
        self.normals = normals
        self.area = 1
        self.areas = np.ones_like(points[:,0])
        self.n = self.points.shape[0]
        if groups is None:
            self.groups = np.zeros((self.n,), dtype=np.int32)
        elif isinstance(groups, int):
            self.groups = groups + np.zeros((self.n,), dtype=np.int32)
        else:
            self.groups = groups
        if ids is None:
            self.ids = np.arange(0, self.n, dtype=np.int32)
        else:
            self.ids = ids
    
    def __len__(self):
        """
        Returns length
        """
        return self.n
    
    def compute_weights(self, area, weight_normals=True):
        """
        Note that the area must be set explicitly by this function, otherwise it will be assumed to be 1 in the constructor
        Computes weights used for discretizing the integration
        Lazy implementation so I'm just multiplying the normals by the weights so that they can be used in the Barnes-Hut algorithm implicitly
        """
        # Library implementation should be O(nlogn)
        nbrs = NearestNeighbors(n_neighbors=10).fit(self.points)
        self.area = area
        distances, indices = nbrs.kneighbors(self.points)
        self.weights = np.mean(distances, axis=-1)**2
        self.weights *= (area / np.sum(self.weights))
        if weight_normals:
            self.normals = self.weights[:,None] * self.normals

    def get_bounding_box(self):
        """
        Returns bounding box slightly (1.2 x) larger than the smallest bounding box
        """
        bbox = BoundingBox([np.min(self.points, axis=0), np.max(self.points, axis=0)], boundary="[][][]")
        center = bbox.center()
        width = bbox.width()
        new_bbox = BoundingBox([center - 0.6 * width, center + 0.6 * width], boundary="[][][]")
        return new_bbox
    
    def merge(self, o):
        """
        Returns a merged point cloud of self and o
        """
        points = np.concatenate([self.points, o.points], axis=0)
        normals = np.concatenate([self.normals, o.normals], axis=0)
        groups = np.concatenate([self.groups, o.groups], axis=0)
        ids = np.concatenate([self.ids, o.ids], axis=0)
        return PointCloud(points, normals, groups, ids)

class Octree():
    """
    Simple octree class
    """
    def __init__(self, pcd, bbox=None, max_height=None, parent=None):
        """
        Creates octree from a set of points
        """
        self.max_height = max_height
        self.parent = parent
        self.children = [[[None, None], [None, None]], [[None, None], [None, None]]]
        if bbox is None:
            self.bbox = pcd.get_bounding_box()
        else:
            self.bbox = bbox
        self.pcd = None
    
        if len(pcd) > 1 and self.max_height != 0:
            for i1 in [0, 1]:
                for i2 in [0, 1]:
                    for i3 in [0, 1]:
                        child_bbox = self.bbox.halve(axis=0)[i1]
                        child_pcd = child_bbox.filter_point_cloud(pcd)
                        child_bbox = child_bbox.halve(axis=1)[i2]
                        child_pcd = child_bbox.filter_point_cloud(child_pcd)
                        child_bbox = child_bbox.halve(axis=2)[i3]
                        child_pcd = child_bbox.filter_point_cloud(child_pcd)
                        self.children[i1][i2][i3] = Octree(child_pcd, child_bbox, None if max_height is None else max_height-1)
        else:
            self.pcd = pcd
    
    def get_centers(self):
        """
        Returns centers of nodes in the octree
        Used for choosing evaluation targets
        """
        centers = np.array(self.bbox.center())[None,:]
        for i1 in [0, 1]:
            for i2 in [0, 1]:
                for i3 in [0, 1]:
                    if self.children[i1][i2][i3] is not None:
                        centers = np.concatenate((centers, self.children[i1][i2][i3].get_centers()), axis=0)
        return centers
    
    def fill_grid(self, grid, indicator, targets, xyz, mask):
        """
        Assuming indicator has been computed for sparse points (targets), fills denser grid (grid) using octree structure
        grid is mutable numpy array of shape (m,) and used as the output
        xyz is the coordinates underlying grid so it has shape (m, 3)
        indicator has shape (n,)
        targets has shape (n, 3)
        mask is a numpy boolean array of shape (m,), just set to all True when calling
        """
        target_pcd = PointCloud(targets, np.zeros_like(targets))
        target_filt = self.bbox.get_filter(target_pcd)
        if np.sum(target_filt) == 0:
            return
        val = np.mean(indicator[target_filt])
        targets = targets[target_filt,:]
        indicator = indicator[target_filt]

        xyz_pcd = PointCloud(xyz[mask], np.zeros_like(xyz[mask]))
        filt = self.bbox.get_filter(xyz_pcd)
        ind = np.flatnonzero(mask)[filt]
        grid[ind] = val
        new_mask = np.zeros_like(mask)
        new_mask[ind] = 1
        # print(mask.shape, mask.sum(), new_mask.sum())
        for i1 in [0, 1]:
            for i2 in [0, 1]:
                for i3 in [0, 1]:
                    if self.children[i1][i2][i3] is not None:
                        self.children[i1][i2][i3].fill_grid(grid, np.array(indicator), np.array(targets), xyz, np.array(new_mask))
In [4]:
def load_pcd(filename, N):
    """
    Wrapper function for loading point cloud from mesh file with N uniform samples
    """
    mesh = o3d.io.read_triangle_mesh(filename)
    mesh.compute_vertex_normals()
    area = mesh.get_surface_area()
    pcd = mesh.sample_points_uniformly(N)
    points = np.array(pcd.points)
    normals = np.array(pcd.normals)
    pcd = PointCloud(points, normals)
    pcd.compute_weights(area, weight_normals=True)
    return pcd

def load_sphere(N):
    """
    Wrapper function for loading sphere point cloud with N uniform samples
    Could also use Gaussian distribution trick but this mesh is accurate enough
    """
    mesh = o3d.geometry.TriangleMesh.create_sphere(1.0, 100)
    mesh.compute_vertex_normals()
    area = mesh.get_surface_area()
    pcd = mesh.sample_points_uniformly(N)
    points = np.array(pcd.points)
    normals = np.array(pcd.normals)
    pcd = PointCloud(points, normals)
    pcd.compute_weights(area, weight_normals=True)
    return pcd

def load_cone(N):
    """
    Wrapper function for loading cone point cloud with N uniform samples
    """
    mesh = o3d.geometry.TriangleMesh.create_cone(1.0, 2.0, 100)
    mesh.compute_vertex_normals()
    area = mesh.get_surface_area()
    pcd = mesh.sample_points_uniformly(N)
    points = np.array(pcd.points)
    normals = np.array(pcd.normals)
    pcd = PointCloud(points, normals)
    pcd.compute_weights(area, weight_normals=True)
    return pcd

def load_torus(N):
    """
    Wrapper function for loading torus point cloud with N uniform samples
    """
    mesh = o3d.geometry.TriangleMesh.create_torus(1.0, 0.5, 100, 100)
    mesh.compute_vertex_normals()
    area = mesh.get_surface_area()
    pcd = mesh.sample_points_uniformly(N)
    points = np.array(pcd.points)
    normals = np.array(pcd.normals)
    pcd = PointCloud(points, normals)
    pcd.compute_weights(area, weight_normals=True)
    return pcd
In [5]:
octree_nodes = []

class FMMOctree:
    """
    Octree structure
    Used to prepare and apply Barnes-Hut
    """
    def __init__(self, pcd, bbox=None, max_height=None, parent=None, id=0):
        """
        Each node in the octree maintains a copy of the points within its bounding box
        An ID system is used to maintain the parent and child relations, as well as the near neighbour and interaction lists
        """
        global octree_nodes
        if id == 0:
            octree_nodes.clear()
        octree_nodes.append(self)
        self.max_height = max_height
        self.parent = parent
        self.id = id
        self.max_id = id
        self.flattened_children = []
        if bbox is None:
            self.bbox = pcd.get_bounding_box()
        else:
            self.bbox = bbox
        self.pcd = pcd
    
        if len(pcd) > 1 and self.max_height != 0:
            for i1 in [0, 1]:
                child_bbox1 = self.bbox.halve(axis=0)[i1]
                child_pcd1 = child_bbox1.filter_point_cloud(pcd)
                for i2 in [0, 1]:
                    child_bbox2 = child_bbox1.halve(axis=1)[i2]
                    child_pcd2 = child_bbox2.filter_point_cloud(child_pcd1)
                    for i3 in [0, 1]:
                        child_bbox3 = child_bbox2.halve(axis=2)[i3]
                        child_pcd3 = child_bbox3.filter_point_cloud(child_pcd2)
                        if len(child_pcd3) != 0:
                            child = FMMOctree(child_pcd3, child_bbox3, None if max_height is None else max_height-1, self.id, self.max_id+1)
                            self.max_id = max(self.max_id, child.max_id)
                            self.flattened_children.append(child.id)
        self.neighbours = [self.id]
        self.interaction_list = []
    
    def build_bridge_neighbours(self):
        """
        When building the near neighbour list, consider neighbours which have the same parent and those which don't
        This function forms connections for the latter
        Note that it is the parent controlling this
        """
        global octree_nodes
        for neighbour in self.neighbours:
            if neighbour == self.id:
                continue
            for child in self.flattened_children:
                for other_child in octree_nodes[neighbour].flattened_children:
                    diff = octree_nodes[other_child].bbox.center() - octree_nodes[child].bbox.center()
                    check = np.abs(diff) <= (octree_nodes[child].bbox.corners[1] - octree_nodes[child].bbox.corners[0]) * (1 + 1e-6)
                    if np.sum(check) == check.shape[0]:
                        octree_nodes[child].neighbours.append(other_child)

    def build_child_neighbours(self):
        """
        When building the near neighbour list, consider neighbours which have the same parent and those which don't
        This function forms connections for the former
        Note that it is the parent controlling this
        """
        global octree_nodes
        for i in range(len(self.flattened_children)):
            for j in range(i+1, len(self.flattened_children)):
                octree_nodes[self.flattened_children[i]].neighbours.append(self.flattened_children[j])
                octree_nodes[self.flattened_children[j]].neighbours.append(self.flattened_children[i])
        self.build_bridge_neighbours()
        for child in self.flattened_children:
            octree_nodes[child].build_child_neighbours()
    
    def build_interaction_list(self):
        """
        Forms the interaction list
        """
        global octree_nodes
        if self.parent is not None:
            for parent_neighbour in octree_nodes[self.parent].neighbours:
                for parent_child in octree_nodes[parent_neighbour].flattened_children:
                    if parent_child in self.neighbours:
                        continue
                    self.interaction_list.append(parent_child)
        for child in self.flattened_children:
            octree_nodes[child].build_interaction_list()
    
    def build_moments(self, p):
        """
        Computes the mean of the sources and their moments as described in the paper
        N^tkm is n_moments and M^tkm is yn_moments
        """
        global octree_nodes
        self.sources = self.pcd.points[self.pcd.groups == 0]
        self.normals = self.pcd.normals[self.pcd.groups == 0]
        self.targets = self.pcd.points[self.pcd.groups == 1]
        if self.sources.shape[0] != 0:
            # store mean
            self.mean = np.mean(self.sources, axis=0)
            # note that if there's only 1 source, then diff = 0 which is annoying
            diff = self.sources-self.mean[None,:]
            # compute spherical coordinates
            rho = np.linalg.norm(diff, axis=-1)
            # mask checks for when diff == 0
            mask = rho == 0
            frho = np.array(rho)
            frho[mask] = 1
            alpha = np.arccos(diff[:,2]/frho)
            beta = np.arctan2(diff[:,1], diff[:,0])
            beta[beta < 0] += 2*np.pi
            beta[beta > 2*np.pi] -= 2*np.pi
            alpha[mask] = 0
            beta[mask] = 0
            self.n_moments = {}
            self.yn_moments = {}
            yn = np.sum(self.sources * self.normals, axis=-1)
            for t in range(1, p+1):
                for j in range(1, (t+1)//2+1):
                    n = t+1-2*j
                    for m in range(-n, n+1):
                        # compute and store O(p^3) moments
                        sphs = sph_harm(m, n, beta, alpha).conj()
                        val = sphs
                        if t > 1:
                            val *= (rho**(t-1))
                        self.n_moments[(t, j, m)] = np.sum(self.normals * val[:,None], axis=0)
                        self.yn_moments[(t, j, m)] = np.sum(yn * val, axis=0)
        
        # recurse
        for child in self.flattened_children:
            octree_nodes[child].build_moments(p)
    
    def barnes_hut_compute(self, indicator, tot, targets, ids, means, n_moments, yn_moments):
        """
        Computes the multipole expansion given the precomputed moments
        Note that the computations for multiple nodes may be concatenated here so that operations are parallelized better
        All variable lists are cleared once the computations are done
        Note that indicator is mutable and treated as the output  
        """
        global harmonics
        # concatenate numpy arrays for parallelization
        target = np.concatenate(targets, axis=0)
        id = np.concatenate(ids, axis=0)
        mean = np.concatenate(means, axis=0)
        n_moment = {}
        for d in n_moments:
            n_moment[d] = np.concatenate(n_moments[d], axis=0)
        yn_moment = {}
        for d in yn_moments:
            yn_moment[d] = np.concatenate(yn_moments[d], axis=0)
        
        diff = target - mean
        # get spherical coordinates
        r = np.linalg.norm(diff, axis=-1)
        theta = np.arccos(diff[:,2]/r)
        phi = np.arctan2(diff[:,1], diff[:,0])
        phi[phi < 0] += 2*np.pi
        phi[phi > 2*np.pi] -= 2*np.pi
        n_indicator = np.zeros_like(target).astype(np.complex128)
        yn_indicator = np.zeros_like(target[:,0]).astype(np.complex128)

        # perform O(p^3) multipole expansion computations
        for d in n_moment:
            t, j, m = d
            n = t+1-2*j
            sphl = sph_harm(m, n, phi, theta)
            n_indicator -= (r**(-t-2))[:,None] * sphl[:,None] * n_moment[d]
            yn_indicator -= (r**(-t-2)) * sphl * yn_moment[d]
        temp_indicator = (np.sum(target * n_indicator, axis=-1) - yn_indicator).real

        # insert results into indicator
        for i in range(temp_indicator.shape[0]):
            indicator[id[i]] += temp_indicator[i]
        targets.clear()
        ids.clear()
        means.clear()
        for d in n_moments:
            n_moments[d].clear()
        for d in yn_moments:
            yn_moments[d].clear()
        tot[0] = 0

    def barnes_hut_collect(self, indicator, tot, targets, ids, means, n_moments, yn_moments):
        """
        Collects the variables needed to perform multipole expansion between interaction list members
        Once the length of the variable list gets large enough, the calculation is performed and the variable list cleared
        Also immediately performs the near neighbour calculations
        Note that indicator is mutable and treated as the output  
        """
        global octree_nodes
        temp_indicator = np.zeros((self.targets.shape[0],))
        interaction_list = list(self.interaction_list)
        neighbours = list(self.neighbours)

        # collect variables, stack if necessary
        for source_box in interaction_list:
            if octree_nodes[source_box].sources.shape[0] == 0 or self.targets.shape[0] == 0:
                continue
            tot[0] += self.targets.shape[0]
            targets.append(self.targets)
            ids.append(self.pcd.ids[self.pcd.groups == 1])
            means.append(np.vstack([octree_nodes[source_box].mean]*self.targets.shape[0]))
            
            for d in octree_nodes[source_box].n_moments.keys():
                if d not in n_moments:
                    n_moments[d] = []
                n_moments[d].append(np.vstack([octree_nodes[source_box].n_moments[d]]*self.targets.shape[0]))
                if d not in yn_moments:
                    yn_moments[d] = []
                yn_moments[d].append(np.vstack([octree_nodes[source_box].yn_moments[d]]*self.targets.shape[0]).squeeze(-1))
        target_ids = self.pcd.ids[self.pcd.groups == 1]

        # perform near neighbour computations
        for neighbour in neighbours:
            if len(octree_nodes[neighbour].flattened_children) == 0 or len(self.flattened_children) == 0:
                sources = octree_nodes[neighbour].sources
                normals = octree_nodes[neighbour].normals
                diff = self.targets[:,None,:] - sources[None,:,:]
                dis = np.linalg.norm(diff, axis=-1)
                kernel = -1/4/np.pi * np.sum(diff * normals[None,:,:], axis=-1) / np.power(dis, 3)
                w = octree_nodes[neighbour].bbox.width()
                # kernel[dis<2*w] = 0
                temp_indicator += np.sum(kernel, axis=1)
        for i in range(temp_indicator.shape[0]):
            indicator[target_ids[i]] += temp_indicator[i]
        
        # if variable list is long, perform the computation
        if tot[0] > 100000:
            self.barnes_hut_compute(indicator, tot, targets, ids, means, n_moments, yn_moments)

        # recurse
        for child in self.flattened_children:
            octree_nodes[child].barnes_hut_collect(indicator, tot, targets, ids, means, n_moments, yn_moments)
In [6]:
def psr_solve(pcd):
    """
    Library implementation of Screened Poisson Surface Reconstruction
    """
    mesh = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd)

def fast_solve(sources, normals, targets, p=4):
    """
    Evaluates the integral equation at each target point given sources and normals
    sources and normals  are numpy arrays of shape (N, 3)
    targets is a numpy array of shape (M, 3)
    Uses an O((N+M) log (N+M)) Barnes-Hut algorithm
    """
    pcd = PointCloud(sources, normals, 0)
    full_pcd = pcd.merge(PointCloud(targets, np.zeros_like(targets), 1))
    fmm = FMMOctree(full_pcd, max_height=8)
    # compute near neighbours and interaction list
    # note that we do this in separate passes, although they can technically all be done in a single pass
    fmm.build_child_neighbours()
    fmm.build_interaction_list()
    # build moments
    fmm.build_moments(p)
    indicator = [0 for i in range(targets.shape[0])]
    # provides mutable containers
    tot = [0]
    targets_list = []
    ids_list = []
    means_list = []
    n_moments_list = {}
    yn_moments_list = {}
    fmm.barnes_hut_collect(indicator, tot, targets_list, ids_list, means_list, n_moments_list, yn_moments_list)
    # finishes off computation for remaining variables in lists
    fmm.barnes_hut_compute(indicator, tot, targets_list, ids_list, means_list, n_moments_list, yn_moments_list)
    indicator = np.array(indicator, dtype=np.float64)
    return indicator

def slow_solve(sources, normals, targets):
    """
    Evaluates the integral equation at each target point given sources and normals
    sources and normals  are numpy arrays of shape (N, 3)
    targets is a numpy array of shape (M, 3)
    Uses the O(NM) brute-force approach
    """
    diff = targets[:,None,:] - sources[None,:,:]
    dis = np.linalg.norm(diff, axis=-1)
    kernel = -1/4/np.pi * np.sum(diff * normals[None,:,:], axis=-1) / np.power(dis, 3)
    indicator = np.sum(kernel, axis=1)
    return indicator
In [7]:
def test_sphere(n, fast=True):
    """
    Tests our methods on a point cloud with n points sampled from the unit sphere
    If fast is set to True, Barnes-Hut is used, else naive
    Plots the indicator against the radius of the target
    """
    pcd = load_sphere(n)
    ot = Octree(pcd)
    sources = pcd.points.astype(np.float64)
    normals = pcd.normals.astype(np.float64)
    targets = ot.get_centers()[:sources.shape[0],:].astype(np.float64)
    indicator = fast_solve(sources, normals, targets) if fast else slow_solve(sources, normals, targets)

    rad = np.linalg.norm(targets, axis=-1)

    plt.figure()
    plt.title(("Barnes-Hut" if fast else "Naive") + " Indicator for Unit Sphere with {:} Sources".format(sources.shape[0]))
    plt.xlabel("Radius")
    plt.ylabel("Indicator")
    plt.scatter(rad, indicator)
    plt.ylim(bottom=-1, top=2)
    plt.grid()
    plt.show()

test_sphere(1000, True)
test_sphere(1000, False)
In [8]:
def check_harmonic():
    """
    Verifies the multipole expansion derived in the paper
    """
    p = 50
    x = np.array([[3.0, 1.0, 1.0]])
    y = np.array([[1.0, 4.0, 4.1]])
    rv = np.random.uniform(-1, 1, (1,3))
    yb = y + rv * np.linalg.norm(x-y)/np.linalg.norm(rv) * 0.2

    true_kernel = -1/4/np.pi/np.power(np.linalg.norm(x-y), 3)
    dl = x-yb
    ds = y-yb
    print("cartesian")
    print(dl)
    print(ds)
    r = np.linalg.norm(dl, axis=-1)
    theta = np.arccos(dl[:,2]/r)
    phi = np.arctan2(dl[:,1], dl[:,0])
    phi[phi < 0] += 2*np.pi
    phi[phi > 2*np.pi] -= 2*np.pi
    rho = np.linalg.norm(ds, axis=-1)
    alpha = np.arccos(ds[:,2]/rho)
    beta = np.arctan2(ds[:,1], ds[:,0])
    beta[beta < 0] += 2*np.pi
    beta[beta > 2*np.pi] -= 2*np.pi
    val = 0
    u = np.sum(dl*ds, axis=-1) / (r*rho)
    print("spherical coordinates")
    print(r, theta, phi)
    print(rho, alpha, beta)
    print("spherical to cartesian")
    print(r*np.cos(phi)*np.sin(theta), r*np.sin(phi)*np.sin(theta), r*np.cos(theta))
    print(rho*np.cos(beta)*np.sin(alpha), rho*np.sin(beta)*np.sin(alpha), rho*np.cos(alpha))
    for t in range(1, p+1):
        for j in range(1, (t+1)//2+1):
            n = t+1-2*j
            for m in range(-n, n+1):
                sphl = sph_harm(m, n, phi, theta)
                sphs = sph_harm(m, n, beta, alpha).conj()
                val += (r**(-t-2)) * (rho**(t-1)) * sphl * sphs
    val *= -1
    print("Direct computation:", true_kernel)
    print("Multipole expansion with p={:}:".format(p), val.real)

check_harmonic()
cartesian
[[ 2.14281373 -3.37920128 -2.23964506]]
[[ 0.14281373 -0.37920128  0.86035494]]
spherical coordinates
[4.58548382] [2.08107526] [5.2775185]
[0.95099947] [0.44015652] [5.07257672]
spherical to cartesian
[2.14281373] [-3.37920128] [-2.23964506]
[0.14281373] [-0.37920128] [0.86035494]
Direct computation: -0.0007401833172703246
Multipole expansion with p=50: [-0.00074018]
In [9]:
def build_mesh():
    """
    Generates the indicator function from a point cloud with oriented normals
    Targets are chosen to be close to the surface by picking from an octree
    The evaluated points are then used to fill in a regular grid which is then fed into Marching Cubes
    """
    N = 256
    pcd = load_pcd("models/bunny_1k.obj", N)
    #pcd = loader.load_sphere(N)
    octree = Octree(pcd)
    sources = pcd.points
    normals = pcd.normals
    targets = octree.get_centers()
    indicator = fast_solve(sources, normals, targets)

    # note that filling in the dense regular grid from the sparse samples is relatively fast
    xyz = octree.bbox.meshgrid(32, 32, 32)
    grid = np.zeros_like(xyz[:,:,:,0])
    shp = xyz.shape[:-1]
    xyz = np.reshape(xyz, (-1, 3))
    grid = np.reshape(grid, (-1,))
    mask = np.ones((grid.shape[0],), dtype=bool)
    octree.fill_grid(grid, indicator, targets, xyz, mask)
    grid = np.reshape(grid, shp)
    verts, faces, normals, values = measure.marching_cubes(grid, 0.5)
    
    mesh = o3d.geometry.TriangleMesh()
    mesh.vertices = o3d.utility.Vector3dVector(verts)
    mesh.triangles = o3d.utility.Vector3iVector(faces[:,::-1])
    mesh.compute_triangle_normals()
    mesh.compute_vertex_normals()

    # not sure how to save, need to run this explicitly
    o3d.visualization.draw_geometries([mesh])

build_mesh()
In [10]:
def evaluate_runtime():
    """
    Times the methods
    Also provides an error graph for changing N
    """
    dN = range(5, 15)
    Ns = []
    slow_times = []
    fast_times = []
    psr_times = []
    error = []
    for i in range(len(dN)):
        N = 1 << dN[i]
        Ns.append(N)
        pcd = load_sphere(N)
        ot = Octree(pcd)
        sources = pcd.points.astype(np.float64)
        normals = pcd.normals.astype(np.float64)
        targets = ot.get_centers()[:sources.shape[0],:].astype(np.float64)
        start_time = time.time()
        slow_indicator = slow_solve(sources, normals, targets)
        end_time = time.time()
        slow_times.append(end_time-start_time)
        start_time = time.time()
        fast_indicator = fast_solve(sources, normals, targets)
        end_time = time.time()
        fast_times.append(end_time-start_time)

        onormals = normals / np.linalg.norm(normals, axis=-1)[:,None]
        opcd = o3d.geometry.PointCloud()
        opcd.points = o3d.utility.Vector3dVector(sources)
        opcd.normals = o3d.utility.Vector3dVector(onormals)
        start_time = time.time()
        psr_solve(opcd)
        end_time = time.time()
        psr_times.append(end_time-start_time)

        diff = slow_indicator - fast_indicator
        error.append(np.amax(np.abs(diff)))
    
    plt.figure()
    plt.title("Runtimes for Integral Evaluation on Unit Sphere")
    plt.xlabel("N")
    plt.ylabel("Runtime (s)")
    plt.xscale("log", base=2)
    plt.yscale("log", base=2)
    plt.grid()
    plt.plot(Ns, slow_times, label="Naive")
    plt.plot(Ns, fast_times, label="Barnes-Hut")
    plt.plot(Ns, psr_times, label="Poisson Surface Reconstruction")
    print("SLOW", slow_times)
    print("FAST", fast_times)
    print("PSR", psr_times)
    plt.legend()
    plt.show()

    plt.figure()
    plt.title("Error for Integral Evaluation on Unit Sphere")
    plt.xlabel("N")
    plt.ylabel("Error")
    plt.xscale("log", base=2)
    plt.yscale("log", base=2)
    plt.grid()
    plt.plot(Ns, error)
    print("ERROR", error)
    plt.show()

evaluate_runtime()
SLOW [0.0, 0.000997304916381836, 0.000997304916381836, 0.004986286163330078, 0.028922557830810547, 0.08577132225036621, 0.356015682220459, 1.456134557723999, 5.888250350952148, 89.46137070655823]
FAST [0.15458273887634277, 0.416886568069458, 1.0083329677581787, 2.4674012660980225, 5.697758197784424, 12.26818323135376, 27.12453532218933, 60.92998456954956, 135.7829785346985, 276.152206659317]
PSR [0.22142553329467773, 0.2313852310180664, 0.21439743041992188, 0.24534249305725098, 0.22140741348266602, 0.23337435722351074, 0.23935961723327637, 0.3480672836303711, 0.3949427604675293, 0.8873372077941895]
ERROR [9.344194405469519e-05, 0.0017230328717544596, 0.007677283685422087, 0.005060738895434569, 0.005542389736835407, 0.005490284755753971, 0.006314796826543234, 0.006937496000887111, 0.007228842260259993, 0.008937695447018085]
In [11]:
def evaluate_p():
    """
    Times the methods and provides an error graph for changing p
    """
    N = 64
    ps = range(1, 17)
    fast_times = []
    error = []
    for i in range(len(ps)):
        pcd = load_sphere(N)
        ot = Octree(pcd)
        sources = pcd.points.astype(np.float64)
        normals = pcd.normals.astype(np.float64)
        targets = ot.get_centers()[:sources.shape[0],:].astype(np.float64)
        start_time = time.time()
        slow_indicator = slow_solve(sources, normals, targets)
        end_time = time.time()

        start_time = time.time()
        fast_indicator = fast_solve(sources, normals, targets, ps[i])
        end_time = time.time()
        fast_times.append(end_time-start_time)
        diff = slow_indicator - fast_indicator
        error.append(np.amax(np.abs(diff)))
    
    pc = [i**3 for i in ps]
    plt.figure()
    plt.title("Runtimes for Integral Evaluation on Unit Sphere with N = 64")
    plt.xlabel("p^3")
    plt.ylabel("Runtime (s)")
    # plt.xscale("log", base=2)
    # plt.yscale("log", base=2)
    plt.grid()
    plt.plot(pc, fast_times)
    print("FAST", fast_times)
    plt.legend()
    plt.show()

    plt.figure()
    plt.title("Error for Integral Evaluation on Unit Sphere with N = 64")
    plt.xlabel("p")
    plt.ylabel("Error")
    # plt.xscale("log", base=2)
    plt.yscale("log", base=2)
    plt.grid()
    plt.plot(ps, error)
    plt.legend()
    plt.show()

evaluate_p()
No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.
FAST [0.3779885768890381, 0.16057038307189941, 0.24135303497314453, 0.3839740753173828, 0.593442440032959, 0.8387274742126465, 1.2057747840881348, 1.696455955505371, 2.378607749938965, 3.060812473297119, 3.998304843902588, 5.156172752380371, 6.632258892059326, 8.584449529647827, 10.937072277069092, 11.603961944580078]
No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.
In [12]:
def evaluate_p_and_N():
    """
    Times the methods and provides an error graph for changing p and N
    """
    dN = range(5, 10)
    ps = [2, 4, 8, 16]
    Ns = []
    slow_times = []
    fast_times = [[] for p in ps]
    error = [[] for p in ps]
    for i in range(len(dN)):
        N = 1 << dN[i]
        Ns.append(N)
        pcd = load_sphere(N)
        ot = Octree(pcd)
        sources = pcd.points.astype(np.float64)
        normals = pcd.normals.astype(np.float64)
        targets = ot.get_centers()[:sources.shape[0],:].astype(np.float64)
        start_time = time.time()
        slow_indicator = slow_solve(sources, normals, targets)
        end_time = time.time()
        slow_times.append(end_time-start_time)
        for j in range(len(ps)):
            print(N, j)
            start_time = time.time()
            fast_indicator = fast_solve(sources, normals, targets, ps[j])
            end_time = time.time()
            fast_times[j].append(end_time-start_time)
            diff = slow_indicator - fast_indicator
            error[j].append(np.amax(np.abs(diff)))
    
    plt.figure()
    plt.title("Runtimes for Integral Evaluation on Unit Sphere")
    plt.xlabel("N")
    plt.ylabel("Runtime (s)")
    plt.xscale("log", base=2)
    plt.yscale("log", base=2)
    plt.grid()
    plt.plot(Ns, slow_times, label="Naive")
    for j in range(len(ps)):
        plt.plot(Ns, fast_times[j], label="Barnes-Hut (p={})".format(ps[j]))
    print("SLOW", slow_times)
    print("FAST", fast_times)
    plt.legend()
    plt.show()

    plt.figure()
    plt.title("Error for Integral Evaluation on Unit Sphere")
    plt.xlabel("N")
    plt.ylabel("Error")
    plt.xscale("log", base=2)
    plt.yscale("log", base=2)
    plt.grid()
    for j in range(len(ps)):
        plt.plot(Ns, error[j], label="p={}".format(ps[j]))
    plt.legend()
    plt.show()

evaluate_p_and_N()
32 0
32 1
32 2
32 3
64 0
64 1
64 2
64 3
128 0
128 1
128 2
128 3
256 0
256 1
256 2
256 3
512 0
512 1
512 2
512 3
SLOW [0.0, 0.0, 0.0009984970092773438, 0.004987239837646484, 0.021942138671875]
FAST [[0.07978940010070801, 0.16655516624450684, 0.3749964237213135, 0.8669297695159912, 1.968766450881958], [0.1266939640045166, 0.3839402198791504, 1.0362284183502197, 2.146228551864624, 5.111295223236084], [0.5624973773956299, 1.7653083801269531, 4.399263143539429, 10.068067789077759, 25.57215404510498], [3.4707140922546387, 11.612904787063599, 27.883418321609497, 70.079660654068, 186.57931184768677]]