diff --git a/src/gpytoolbox/__init__.py b/src/gpytoolbox/__init__.py index 0a651550..1d872f82 100644 --- a/src/gpytoolbox/__init__.py +++ b/src/gpytoolbox/__init__.py @@ -108,7 +108,8 @@ from .read_dmat import read_dmat from .linear_blend_skinning import linear_blend_skinning from .barycenters import barycenters -from .sdf_flow import sdf_flow +from .reach_for_the_spheres import reach_for_the_spheres +from .reach_for_the_spheres import reach_for_the_spheres_iteration from .cut_edges import cut_edges from .biharmonic_energy import biharmonic_energy from .biharmonic_energy_intrinsic import biharmonic_energy_intrinsic diff --git a/src/gpytoolbox/sdf_flow.py b/src/gpytoolbox/reach_for_the_spheres.py similarity index 54% rename from src/gpytoolbox/sdf_flow.py rename to src/gpytoolbox/reach_for_the_spheres.py index 2ec30950..6f3e6218 100644 --- a/src/gpytoolbox/sdf_flow.py +++ b/src/gpytoolbox/reach_for_the_spheres.py @@ -21,11 +21,9 @@ from .random_points_on_mesh import random_points_on_mesh -def sdf_flow(U, sdf, V, F, S=None, +def reach_for_the_spheres(U, sdf, V, F, S=None, return_U=False, verbose=False, - visualize=False, #TODO: remove - callback=None, #TODO: remove max_iter=None, tol=None, h=None, min_h=None, linesearch=None, min_t=None, max_t=None, dt=None, @@ -37,120 +35,172 @@ def sdf_flow(U, sdf, V, F, S=None, remesh_iterations=None, batch_size=None, fix_boundary=None, - clamp=None, sv=None): + clamp=None, pseudosdf_interior=None): + """Creates a mesh from a signed distance function (SDF) using the + "Reach for the Spheres" method of S. Sellán, C. Batty, and O. Stein [2023]. - state = SdfFlowState(V=V, F=F, sdf=sdf, U=U, S=S, + This method takes in an sdf, sample points (do not need to be on a grid), + and an initial mesh. + It then flows this initial mesh into a reconstructed mesh that fulfills + the signed distance function. + + This method works in dimension 2 (dim==2), where it reconstructs a polyline, + and in dimension 3 (dim==3), where it reconstructs a triangle mesh. + + Parameters + ---------- + U : (k,dim) numpy double array + Matrix of SDF sample positions. + The sdf will be sampled at these points + sdf: lambda function that takes in a (ks,dim) numpy double array + The signed distance function to be reconstructed. + This function must take in a (ks,dim) matrix of sample points and + return a (ks,) array of SDF values at these points. + V : (n,dim) numpy double array + Matrix of mesh vertices of the initial mesh + F : (m,dim) numpy int array + Matrix of triangle indices into V of the initlal mesh + S : (k,dim) nummpy double array, optional (default: None) + Matrix of SDF samples at the sample positions in U. + If this is not provided, it will be computed using sdf. + return_U : bool, optional (default: False) + Whether to return the matrix of SDF sample positions along with the + reconstructed mesh. + verbose : bool (default false) + Whether to print method statistics during operation. + max_iter : int (default None) + The maximum number of iterations to perform for the method. + If not supplied, a sensible default is used. + tol : float (default None) + The method's tolerance for the sphere tangency test. + If not supplied, a sensible default is used. + h : float (default None) + The method's initial target mesh length for the reconstructed mesh. + This will change during iteration, set min_h if you want to control the + minimum edge length overall. + If not supplied, a sensible default is used. + min_h : float (default None) + The method's minimal target edge length for the reconstructed mesh. + If not supplied, a sensible default is used. + linesearch : bool (default None) + Whether to use a linesearch-like heuristic for the method's timestep. + If not supplied, linesearch is used. + min_t : float (default None) + The method's minimum timestep. + If not supplied, a sensible default is used. + max_t : float (default None) + The method's minimum timestep. + If not supplied, a sensible default is used. + dt : float (default None) + The method's default timestep. + If not supplied, a sensible default is used. + inside_outside_test : bool (default None) + Whether to use inside-outside testing when projecting points to be + tangent to the sphere. + Turn this off if your distance function is *unsigned*. + If not supplied, inside-outside test is used + resample : int (default None) + How often to resample the SDF after convergence to extract more + information. + If not supplied, resampling is not performed. + resample_samples : int (default None) + How many samples to use when resampling. + If not supplied, a sensible default is used. + feature_detection : string (default None) + Which feature detection mode to use. + If not supplied, aggressive feature detection is used. + output_sensitive : bool (default None) + Whether to use output-sensitive remeshing. + If not supplied, remeshing is output-sensitive. + remesh_iterations : int (default None) + How many iterations of the remesher to run each step. + If not supplied, a sensible default is used. + batch_size : int (default None) + For large amounts of sample points, the method is sped up using sample + point batching. + This parameter specifies how many samples to take for each batch. + Set it to 0 to disable batching. + If not supplied, a sensible default is used. + fix_boundary : bool (default None) + Whether to fix the boundary of the mesh during iteration. + If not supplied, the boundary is not fixed. + clamp : float (default None) + If sdf is a clamped SDF, the clamp value to use. + np.inf for no clamping. + If not supplied, there is no clamping. + pseudosdf_interior : bool (default None) + If enabled, treats every negative SDF value as a bound on the signed + distance, as opposed to an exact signed distance, for use in SDFs + resulting from CSG unions as described by Marschner et al. + "Constructive Solid Geometry on Neural Signed Distance Fields" [2023]. + If not supplied, this feature is disabled. + + Returns + ------- + Vr : (nr,dim) numpy double array + Matrix of mesh vertices of the reconstructed triangle mesh + Fr : (mr,dim) numpy int array + Matrix of triangle indices into Vr of the reconstructed mesh + Ur : (kr,dim) numpy double array, if requested + Matrix of SDF sample positions. + This can be different from the supplied Ur if the method is set to + resample. + + See Also + -------- + marching_squares, marching_cubes + + Notes + -------- + This method has a number of limitations that are described in the paper. + E.g., the method will only work for SDFs that describe surfaces with the + same topology as the initial surface. + + Examples + -------- + ```python + import gpytoolbox as gpy + import numpy as npy + + # Get a signed distance function + V,F = gpy.read_mesh("my_mesh.obj") + + # Create an SDF for the mesh + j = 20 + sdf = lambda x: gpy.signed_distance(x, V, F)[0] + gx, gy, gz = np.meshgrid(np.linspace(-1.0, 1.0, j+1), np.linspace(-1.0, 1.0, j+1), np.linspace(-1.0, 1.0, j+1)) + U = np.vstack((gx.flatten(), gy.flatten(), gz.flatten())).T + + # Choose an initial surface for reach_for_the_spheres + V0, F0 = gpy.icosphere(2) + + # Reconstruct triangle mesh + Vr,Fr = gpy.reach_for_the_spheres(U, sdf, V0, F0) + + #The reconstructed triangle mesh is now Vr,Fr. + ``` + """ + + + state = ReachForTheSpheresState(V=V, F=F, sdf=sdf, U=U, S=S, h=h, min_h=min_h) converged = False - # Little hack to pass max_iter, which is not keps as state. - # Set to the same default as in sdf_flow_iteration. - dim = V.shape[1] - pass_max_iter = (10000 if dim==2 else 20000 - ) if max_iter is None else max_iter - - # TODO: Replace this function with a simple while loop that breaks if converged. - def run_flow_iteration(): - nonlocal state, converged - if not converged: - converged = sdf_flow_iteration(state, - max_iter=max_iter, tol=tol, - linesearch=linesearch, min_t=min_t, max_t=max_t, - dt=dt, - inside_outside_test=inside_outside_test, - resample=resample, - resample_samples=resample_samples, - feature_detection=feature_detection, - output_sensitive=output_sensitive, - remesh_iterations=remesh_iterations, - batch_size=batch_size, - verbose=verbose, - fix_boundary=fix_boundary, - clamp=clamp, sv=sv) - - # TODO: remove callback code - if callback is not None: - callback({'V':state.V, 'F':state.F, - 'V_active':state.V_active, - 'F_active':state.F_active, - 'V_inactive':state.V_inactive, - 'F_inactive':state.F_inactive, - 'its':state.its, - 'max_iter':pass_max_iter, - 'U':state.U, 'S':state.S, - 'converged':converged, - 'resample_counter':state.resample_counter}) - - # TODO: remove visualization code - if visualize: - if dim==2: - def plot_edges(vv,ee,plt_str): - ax = plt.gca() - for i in range(ee.shape[0]): - ax.plot([vv[ee[i,0],0],vv[ee[i,1],0]], - [vv[ee[i,0],1],vv[ee[i,1],1]], - plt_str,alpha=1) - def plot_spheres(vv,sdf): - ax = plt.gca() - f = sdf(vv) - for i in range(vv.shape[0]): - c = 'r' if f[i]>=0 else 'b' - ax.add_patch(plt.Circle(vv[i,:], f[i], color=c, fill=False,alpha=0.1)) - plt.cla() - plot_spheres(U,sdf) - visualize_full = True - if state.V_active is not None and state.F_active is not None: - visualize_full = False - plot_edges(state.V_active,state.F_active,'b-') - plt.plot(state.V_active[:,0],state.V_active[:,1],'b.') - if state.V_inactive is not None and state.F_inactive is not None: - visualize_full = False - plot_edges(state.V_inactive,state.F_inactive,'y-') - plt.plot(state.V_inactive[:,0],state.V_inactive[:,1],'y.') - if visualize_full and state.V is not None and state.F is not None: - plot_edges(state.V,state.F,'b-') - plt.plot(state.V[:,0],state.V[:,1],'b.') - plt.draw() - plt.pause(0.01) - elif dim==3: - # if stopped: - # # This mess is so that we can render something from polyscope in the same script, otherwise this callback will keep executing and deleting everything you plot. - # if active_ps is not None: - # active_ps.remove() - # active_ps = None - # if inactive_ps is not None: - # inactive_ps.remove() - # inactive_ps = None - # if full_ps is not None: - # full_ps.remove() - # full_ps = None - # # polyscope.remove_all_structures() - # else: - # cloud_U = polyscope.register_point_cloud("SDF evaluation points", U) - # cloud_U.add_scalar_quantity("How unhappy?", np.abs(g), enabled=True) - visualize_full = True - if state.V_active is not None and state.F_active is not None: - visualize_full = False - active_ps = polyscope.register_surface_mesh("active", state.V_active, state.F_active) - if state.V_inactive is not None and state.F_inactive is not None: - visualize_full = False - inactive_ps = polyscope.register_surface_mesh("inactive", state.V_inactive, state.F_inactive) - if visualize_full and V is not None and F is not None: - full_ps = polyscope.register_surface_mesh("full", state.V, state.F) - - # TODO: remove visualization code - if visualize and dim==3: - import polyscope - polyscope.init() - def polyscope_callback(): - run_flow_iteration() - polyscope.set_user_callback(polyscope_callback) - polyscope.show() - else: - if visualize: - import matplotlib.pyplot as plt - while state.its is None or (state.its=max_iter: return True - state.its = state.its+1 #Only here for compatibility, move to end after. #Algorithm if batch_size>0 and batch_size0)*(g<0.)) wu[clamped_g] = 0.0 A = sp.sparse.csc_matrix(((wu[:,None]*b).ravel(), @@ -384,7 +636,7 @@ def sdf_flow_iteration(state, state.F[I,:].ravel())), (state.U_batch.shape[0],state.V.shape[0])) c = wu[:,None]*ps - M = massmatrix(state.V, state.F) + M = _massmatrix(state.V, state.F) rho = 1.0*np.ones(state.U_batch.shape[0]) / A.shape[0] R = sp.sparse.spdiags([rho], 0, rho.shape[0], rho.shape[0]) @@ -437,11 +689,9 @@ def sdf_flow_iteration(state, state.best_avg_error = np.Inf state.convergence_counter = 0 state.h = np.maximum(state.h/2,state.min_h) - # state.use_features = True - converged = False if state.convergence_counter > 100 or F_invalid.shape[0] == 0: if state.resample_counter 0): # we find the invalid faces F_invalid = np.unique(I[invalid_U]) # We compute the face adjacency matrix - TT = face_adjacency(state.F) + TT = _face_adjacency(state.F) # We find the set of invalid faces and their neighbors F_invalid_neighbors = np.unique(TT[F_invalid,:].ravel()) # also add the invalid faces # F_invalid_neighbors = np.unique(np.hstack((F_invalid_neighbors,F_invalid))) - F_invalid_neighbors = one_ring(state.F,F_invalid) + F_invalid_neighbors = _one_ring(state.F,F_invalid) # do another round of one ring - F_invalid_neighbors = one_ring(state.F,F_invalid_neighbors) + F_invalid_neighbors = _one_ring(state.F,F_invalid_neighbors) # We find the set of invalid vertices # F_active = F[F_invalid_neighbors,:] state.V_active, state.F_active, _, _ = remove_unreferenced(state.V, @@ -493,7 +738,7 @@ def sdf_flow_iteration(state, state.V, state.F[state.F_inactive,:],return_maps=True) # Remesh only the active part - state.V_active, state.F_active = remesh( + state.V_active, state.F_active = _remesh( state.V_active, state.F_active, i=remesh_iterations, h=state.h, project=True) # We merge the active and inactive parts @@ -505,10 +750,10 @@ def sdf_flow_iteration(state, state.V,faces=state.F, epsilon=np.sqrt(np.finfo(state.V.dtype).eps)) else: - state.V, state.F = remesh(state.V, state.F, + state.V, state.F = _remesh(state.V, state.F, i=remesh_iterations, h=state.h, project=True) else: - state.V, state.F = remesh(state.V, state.F, + state.V, state.F = _remesh(state.V, state.F, i=remesh_iterations, h=state.h, project = True, feature=feature_vertices) state.V_active = None @@ -522,13 +767,14 @@ def sdf_flow_iteration(state, state.F_inactive = None #Have we converged? - if converged or state.its>=max_iter: + state.its = state.its+1 + if state.its>=max_iter: return True else: return False -def face_adjacency(F): +def _face_adjacency(F): dim = F.shape[1] if dim==2: n = np.max(F)+1 @@ -542,20 +788,7 @@ def face_adjacency(F): return TT -def laplacian(v,f): - dim = v.shape[1] - if dim==3: - L = cotangent_laplacian(v,f) - elif dim==2: - G = grad(v,f) - A = 0.5*doublearea(v,f) - MA = sp.sparse.spdiags(np.concatenate((A,A)), 0, G.shape[0], G.shape[0]) - L = G.transpose() * MA * G - L.data[np.logical_not(np.isfinite(L.data))] = 0. - return L - - -def normals(V,F,unit_norm=False): +def _normals(V,F,unit_norm=False): dim = F.shape[1] if dim==2: e = V[F[:,1],:] - V[F[:,0],:] @@ -565,45 +798,8 @@ def normals(V,F,unit_norm=False): elif dim==3: return per_face_normals(V,F,unit_norm=unit_norm) - -def per_vertex_normals(V,F): - N = normals(V,F,unit_norm=True) - N = np.nan_to_num(N, nan=0., posinf=0., neginf=0.) - Fr = F.ravel(order='F') - dim = F.shape[1] - if dim==2: - Nv = np.stack(( - np.bincount(Fr, weights=np.concatenate((N[:,0],N[:,0]))), - np.bincount(Fr, weights=np.concatenate((N[:,1],N[:,1]))) - ), axis=-1) - elif dim==3: - α = tip_angles(V,F) - αr = α.ravel(order='F') - Nv = np.stack(( - np.bincount(Fr, weights=αr*np.concatenate((N[:,0],N[:,0],N[:,0]))), - np.bincount(Fr, weights=αr*np.concatenate((N[:,1],N[:,1],N[:,1]))), - np.bincount(Fr, weights=αr*np.concatenate((N[:,2],N[:,2],N[:,2]))) - ), axis=-1) - Nv /= np.linalg.norm(Nv, axis=-1)[:,None] - Nv = np.nan_to_num(Nv, nan=0., posinf=0., neginf=0.) - return Nv - - -def barycentric_normals(I,b,V,F,unit_norm=False): - Nv = per_vertex_normals(V,F) - N = np.sum(Nv[F[I,:],:]*b[...,None], axis=1) - if unit_norm: - N /= np.linalg.norm(N, axis=-1)[:,None] - N = np.nan_to_num(N, nan=0., posinf=0., neginf=0.) - return N - - -def processed_normals(I,b,V,F,unit_norm=False): - Nb = barycentric_normals(I,b,V,F,unit_norm=unit_norm) - return Nb - -def massmatrix(V,F): +def _massmatrix(V,F): dim = F.shape[1] if dim==3: l_sq = halfedge_lengths_squared(V,F) @@ -618,7 +814,7 @@ def massmatrix(V,F): return M -def one_ring(F,active_faces): +def _one_ring(F,active_faces): # Vectorized active_verts = F[active_faces,:].ravel() one_ring_active = np.nonzero(np.isin(F,active_verts).any(axis=-1))[0] @@ -641,10 +837,10 @@ def one_ring(F,active_faces): return one_ring_active -def remesh(V, F, i=10, h=None, project=True, feature=np.array([], dtype=int)): +def _remesh(V, F, i=10, h=None, project=True, feature=np.array([], dtype=int)): dim = F.shape[1] if dim==2: - V,F = remesh_line(V, F, + V,F = _remesh_line(V, F, i=i, h=h, project=project, feature=feature) elif dim==3: @@ -659,28 +855,28 @@ def remesh(V, F, i=10, h=None, project=True, feature=np.array([], dtype=int)): return V,F -def line_bdry(F): +def _line_bdry(F): return np.unique(np.concatenate(( np.setdiff1d(F[:,0],F[:,1]), np.setdiff1d(F[:,1],F[:,0]) ))) -def remesh_line(V, F, i=10, h=None, project=True, feature=np.array([], dtype=int)): +def _remesh_line(V, F, i=10, h=None, project=True, feature=np.array([], dtype=int)): high = 1.4*h low = 0.7*h V0,F0 = V.copy(), F.copy() - feature = np.unique(np.concatenate((feature, line_bdry(F)))) + feature = np.unique(np.concatenate((feature, _line_bdry(F)))) for its in range(i): - V,F,feature = split_line(V, F, feature, high, low) - V,F,feature = collapse_line(V, F, feature, high, low) + V,F,feature = _split_line(V, F, feature, high, low) + V,F,feature = _collapse_line(V, F, feature, high, low) if not project: V0,F0 = V.copy(), F.copy() - V,F = relaxation_line(V, F, feature, V0, F0) + V,F = _relaxation_line(V, F, feature, V0, F0) return V,F -def split_line(V, F, feature, high, low): +def _split_line(V, F, feature, high, low): n = V.shape[0] # is_feature = np.full(V.shape[0], False, dtype=bool) # is_feature[feature] = True @@ -701,7 +897,7 @@ def split_line(V, F, feature, high, low): return V,F,feature -def collapse_line(V, F, feature, high, low): +def _collapse_line(V, F, feature, high, low): is_feature = np.full(V.shape[0], False, dtype=bool) is_feature[feature] = True @@ -742,12 +938,12 @@ def collapse_line(V, F, feature, high, low): return V,F,feature -def relaxation_line(V, F, feature, V0, F0): +def _relaxation_line(V, F, feature, V0, F0): is_feature = np.full(V.shape[0], False, dtype=bool) is_feature[feature] = True l = np.linalg.norm(V[F[:,1],:] - V[F[:,0],:], axis=-1) - face_N = normals(V,F,unit_norm=True) + face_N = _normals(V,F,unit_norm=True) N = np.zeros((V.shape[0],2)) N[F[:,0],:] += face_N*l[:,None] N[F[:,1],:] += face_N*l[:,None] @@ -775,7 +971,7 @@ def relaxation_line(V, F, feature, V0, F0): #Bounds for a set of points -def bounds(V, tol=0.): +def _bounds(V, tol=0.): lb = np.min(V, axis=0) ub = np.max(V, axis=0) lb -= (ub-lb)*0.5 - tol @@ -784,14 +980,14 @@ def bounds(V, tol=0.): #void distance function for a given SDF S at points U, evaluated at x -def vdf(x, U, S): +def _vdf(x, U, S): vf = S[None,:]**2 - np.sum((x[:,None,:]-U[None,:,:])**2, axis=-1) v = np.max(vf, axis=1) v = np.minimum(v,0.) return v -def sample_sdf(sdf, +def _sample_sdf(sdf, V, F, U0 = None, #Initial evaluation points new_n = 20, #How many new samples to add each step @@ -808,7 +1004,7 @@ def sample_sdf(sdf, if U0 is None: #Randomly sample twice as many points as initial mesh vertices. - lb,ub = bounds(V, tol) + lb,ub = _bounds(V, tol) n = min(max_n, V.shape[0]) dim = V.shape[1] U0 = rng.uniform(lb, ub, size=(n,dim)) @@ -817,16 +1013,16 @@ def sample_sdf(sdf, # Random points on all faces P,I,_ = random_points_on_mesh(V, F, trial_n, rng=rng, return_indices=True) d = 0.05 * rng.normal(scale=np.max(np.max(V,axis=0)-np.min(V,axis=0)), size=P.shape[0]) - P += d[:,None] * normals(V, F, unit_norm=True)[I,:] + P += d[:,None] * _normals(V, F, unit_norm=True)[I,:] # Remove all points in P that are not worst points on edge. worst = {} for i in range(P.shape[0]): - if (I[i] not in worst) or (vdf(P[i,:][None,:],U0,S0) -