Example: Unwrapping a lamellipodia#

To illustrate the unwrapping steps, we will use an example of a lamellipodia which was segmented from the surface of a cell.

The mesh is located in ../../data/mesh/protrusions/lamellipodia_protrusion_01.obj. We will show:

  1. conformal map to disk

  2. equiareal disk map by relaxing area distortion

  3. obtain a square map by ‘squaring’ the disk

  4. optimize the aspect ratio of the square map using the beltrami coefficient

0. Read in the surface mesh#

import unwrap3D.Mesh.meshtools as meshtools
import unwrap3D.Utility_Functions.file_io as fio # for common IO functions
import unwrap3D.Unzipping.unzip as uzip

import os
import numpy as np
import pylab as plt 

"""
Specifying image file location and parsing its name. 
"""
meshfolder = '../../data/mesh/protrusions'
meshfile = os.path.join(meshfolder, 'lamellipodia_protrusion_01.obj')

basefname = os.path.split(meshfile)[-1].split('.obj')[0] # get the filename with extension

mesh_S = meshtools.read_mesh(meshfile,
                             keep_largest_only=True) # read only the largest if there is multiple separate objects in the mesh

# parse the curvature colormap
curvature_color = mesh_S.visual.vertex_colors[:,:3]

"""
Create a master save folder
"""
savefolder = os.path.join('example_results', 
                         basefname)
fio.mkdir(savefolder) # auto generates the specified folder structure if doesn't currently exist.


"""
Visualize the input mesh
"""
'\nVisualize the input mesh\n'

1. conformal map to the unit disk#

If the input mesh has poor triangle quality, then it is recommended to perform isotropic remesh. Furthermore, If the input mesh has holes, detected by checking the number of outputs of igl.all_boundary_loop after importing the igl library. If it is greater than 1, then you will need to first conduct mesh repair to ‘fill-in’ the holes (all loops that is not the longest) so that the surface is simple.

# check for holes
import igl 

boundaries = igl.all_boundary_loop(mesh_S.faces) # we expect only 1
print('detected %d boundaries' %(len(boundaries)))
assert(len(boundaries)==1)

# the disk mapping is provided via the rectangular conformal mapping function.
disk_coords = meshtools.rectangular_conformal_map(mesh_S.vertices,
                                                  mesh_S.faces,
                                                  corner=None,  
                                                  random_state=0)

print(disk_coords.shape)


# scatter plot the vertices, plot the face connectivity using matplotlib triplot
plt.figure(figsize=(8,8))
plt.scatter(disk_coords[:,0],
            disk_coords[:,1], 
            s=20, 
            c=curvature_color/255.)
plt.triplot(disk_coords[:,0],
            disk_coords[:,1], 
            mesh_S.faces, 
            'k-', lw=.25)
plt.xlim([-1.2,1.2])
plt.ylim([-1.2,1.2])
plt.gca().set_aspect('equal')
plt.show()

# create a mesh of the disk
disk_mesh = mesh_S.copy()
disk_mesh.vertices = np.hstack([np.ones(len(disk_coords))[:,None], 
                                disk_coords]) # we need to make it 3D to save in mesh format
tmp = disk_mesh.export(os.path.join(savefolder,
                                    'conformal_disk_'+basefname+'.obj'))
detected 1 boundaries
(3057, 2)
../_images/8915a9f220097fd440f2b52f950f8ed4f69a921eda39a05ab5c85618ba506d14.png

2. equiareal disk map by relaxation#

Unlike the spherical case, the topology changes in the area relaxation. This is because the relaxation here is much more sensitive to triangulation. Each iteration uses delaunay triangle flipping to improve triangulation and mitigate foldovers. Consequently in each step we have vertices, v and faces, f. The number of vertices is the same, the connectivity of f changes per iteration.

v_steps, f_steps, area_distortion_iter = meshtools.area_distortion_flow_relax_disk(disk_mesh, 
                                                                                    mesh_S, 
                                                                                    max_iter=50,
                                                                                    delta_h_bound=0.5, 
                                                                                    stepsize=1., 
                                                                                    flip_delaunay=True, 
                                                                                    robust_L=False, # we can relax more by setting this off
                                                                                    mollify_factor=1e-5,
                                                                                    eps = 1e-2, # regularization factor
                                                                                    debugviz=False,
                                                                                    debugviz_tri=False) # set True if you want to watch the mesh relax.
  4%|▍         | 2/50 [00:00<00:05,  9.14it/s]
hello
hello
  8%|▊         | 4/50 [00:00<00:05,  9.13it/s]
hello
hello
 12%|█▏        | 6/50 [00:00<00:04,  9.43it/s]
hello
hello
 14%|█▍        | 7/50 [00:00<00:04,  9.49it/s]
hello
hello
 18%|█▊        | 9/50 [00:00<00:04,  9.80it/s]
hello
hello
hello
 24%|██▍       | 12/50 [00:01<00:03,  9.50it/s]
hello
hello
 28%|██▊       | 14/50 [00:01<00:03,  9.12it/s]
hello
hello
 32%|███▏      | 16/50 [00:01<00:03,  8.93it/s]
hello
hello
hello
 40%|████      | 20/50 [00:02<00:03,  9.84it/s]
hello
hello
 44%|████▍     | 22/50 [00:02<00:02, 10.07it/s]
hello
hello
 48%|████▊     | 24/50 [00:02<00:02, 10.20it/s]
hello
hello
 52%|█████▏    | 26/50 [00:02<00:02, 10.25it/s]
hello
hello
 56%|█████▌    | 28/50 [00:02<00:02, 10.37it/s]
hello
hello
hello
 60%|██████    | 30/50 [00:03<00:01, 10.38it/s]
hello
hello
hello
 64%|██████▍   | 32/50 [00:03<00:01, 10.53it/s]
hello
hello
 70%|███████   | 35/50 [00:03<00:01,  8.85it/s]
hello
hello
 74%|███████▍  | 37/50 [00:03<00:01,  8.34it/s]
hello
hello
 78%|███████▊  | 39/50 [00:04<00:01,  8.68it/s]
hello
hello
 82%|████████▏ | 41/50 [00:04<00:00,  9.07it/s]
hello
hello
 86%|████████▌ | 43/50 [00:04<00:00,  9.64it/s]
hello
hello
hello
 94%|█████████▍| 47/50 [00:04<00:00, 10.46it/s]
hello
hello
 98%|█████████▊| 49/50 [00:05<00:00, 10.54it/s]
hello
hello
hello
100%|██████████| 50/50 [00:05<00:00,  9.62it/s]
import unwrap3D.Analysis_Functions.timeseries as tsa

# monitor the mean area distortion per iteration to find the optimal stopping
mean_area_distortion_iter = np.nanmean(np.array(area_distortion_iter),axis=1) 

if len(area_distortion_iter) > 0:
    # smooth the area_distortion_curve helps finding the stopping, unlike spherical case, this curve is typically more bumpy 
    mean_area_distortion_iter = tsa.baseline_als(mean_area_distortion_iter, lam=1, p=0.1) 

    change = np.arange(len(mean_area_distortion_iter))[1:][np.diff(mean_area_distortion_iter)>0] 

    if len(change) > 0: 
        min_distort_id = np.maximum(0, change[0]) 
    else:
        min_distort_id = np.maximum(0,len(mean_area_distortion_iter))
else:
    min_distort_id = 0


# plot the curve and overlay the final 
fig, ax = plt.subplots(figsize=(5,5))
plt.plot(np.arange(0,len(mean_area_distortion_iter)), 
          mean_area_distortion_iter, '.-', color='k')
plt.vlines(min_distort_id, 
            np.nanmin(mean_area_distortion_iter[~np.isinf(mean_area_distortion_iter)]), 
            np.nanmax(mean_area_distortion_iter[~np.isinf(mean_area_distortion_iter)]), 'k', linestyles='dashed', lw=3)
plt.tick_params(length=5, right=True)
plt.xticks(fontsize=16, fontname='Arial')
plt.yticks(fontsize=16, fontname='Arial')
plt.xlim([0,50])
plt.xlabel('Iteration Number', fontsize=18, fontname='Arial')
plt.ylabel('Area distortion loss', fontsize=18, fontname='Arial')
plt.savefig(os.path.join(savefolder, 
                          'area-distortion_relax_iterations_'+basefname.replace('.obj', '.svg')), bbox_inches='tight')
plt.show()
../_images/94bd7b74b645acc678aa8781ef4c02eba47a6e3a6272ed1ef6611df912ec8368.png
# create the new mehs
equiareal_disk_mesh = disk_mesh.copy()
equiareal_disk_mesh.vertices[:,1:] = v_steps[min_distort_id][:,1:] # this is if we want to maintain the same 1st coordinate as the disk_mesh we created earlier
equiareal_disk_mesh.faces = f_steps[min_distort_id] 

tmp = equiareal_disk_mesh.export(os.path.join(savefolder,
                                    'equiareal_disk_'+basefname+'.obj'))

# revisualize: scatterplot the vertices, plot the face connectivity using matplotlib triplot
disk_coords = equiareal_disk_mesh.vertices[:,1:].copy()

plt.figure(figsize=(8,8))
plt.scatter(disk_coords[:,0],
            disk_coords[:,1], 
            s=20, 
            c=curvature_color/255.)
plt.triplot(disk_coords[:,0],
            disk_coords[:,1], 
            equiareal_disk_mesh.faces, 
            'k-', lw=.25)
plt.xlim([-1.2,1.2])
plt.ylim([-1.2,1.2])
plt.gca().set_aspect('equal')
plt.show()
../_images/2f0c5f97b0c41d919cc8490d7b0ff837862d961b8b9296ab185a5a60a4c0c7b4.png

3. squaring the disk#

There are a number of bijective mathematical transformations that have been developed to ‘square the disk’ and the inverse, ‘circularize the square’. See Fong’s Analytical Methods for Squaring the Disc for the mathematical formulae and explanations. These mappings transform the unit disk to the unit square and vice-versa. They do not change the number of vertices or the face connectivity.

u-Unwrap3D implements the squircircle and elliptical-powell in the geometry module, unwrap3D.Geometry.geometry. Here we will use the elliptical-powell method.

import unwrap3D.Geometry.geometry as geom

square_coords = geom.elliptical_nowell(disk_coords)

square_mesh = equiareal_disk_mesh.copy()
square_mesh.vertices[:,1:] = square_coords

tmp = square_mesh.export(os.path.join(savefolder,
                                    'equiareal_square_'+basefname+'.obj'))

# revisualize: scatterplot the vertices, plot the face connectivity using matplotlib triplot

plt.figure(figsize=(8,8))
plt.scatter(square_coords[:,0],
            square_coords[:,1], 
            s=20, 
            c=curvature_color/255.)
plt.triplot(square_coords[:,0],
            square_coords[:,1], 
            square_mesh.faces, 
            'k-', lw=.25)
plt.xlim([-1.2,1.2])
plt.ylim([-1.2,1.2])
plt.gca().set_aspect('equal')
plt.show()
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Geometry\geometry.py:70: RuntimeWarning: invalid value encountered in sqrt
  x = .5*np.sqrt(2.+2.*u*np.sqrt(2) + u**2 - v**2) - .5*np.sqrt(2.-2.*u*np.sqrt(2) + u**2 - v**2)
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Geometry\geometry.py:71: RuntimeWarning: invalid value encountered in sqrt
  y = .5*np.sqrt(2.+2.*v*np.sqrt(2) - u**2 + v**2) - .5*np.sqrt(2.-2.*v*np.sqrt(2) - u**2 + v**2)
../_images/5b8b3c9170b123cfabb0f5209b67a21577ea2e19ecc90206b8d315ad74d8d26a.png

4. optimizing the aspect ratio of the square#

Just as we can optimize the aspect ratio of uv map for spherical parameterization. We can also use criteria such as the Beltrami coefficient to optimize the aspect ratio of the square parmeterization of the open surface.

from scipy.optimize import fminbound
from unwrap3D.Mesh.meshtools import beltrami_coefficient

square = square_coords.copy()
f = mesh_S.faces
v = mesh_S.vertices

def func(h):
    return np.sum(np.abs(beltrami_coefficient(np.hstack([square[:,0][:,None],h*square[:,1][:,None]]), f, v))**2)

h_opt = fminbound(func, 0, 5);
print('optimal length of y-axis as fraction of x-axis: ', h_opt)
square_opt = np.vstack([square[:,0], h_opt*square[:,1]]).T;


square_mesh_opt = square_mesh.copy()
square_mesh_opt.vertices[:,1:] = square_opt

tmp = square_mesh_opt.export(os.path.join(savefolder,
                                    'equiareal_rectangle_'+basefname+'.obj'))

# revisualize: scatterplot the vertices, plot the face connectivity using matplotlib triplot

plt.figure(figsize=(8,8))
plt.scatter(square_opt[:,0],
            square_opt[:,1], 
            s=20, 
            c=curvature_color/255.)
plt.triplot(square_opt[:,0],
            square_opt[:,1], 
            square_mesh_opt.faces, 
            'k-', lw=.25)
plt.xlim([-1.2,1.2])
plt.gca().set_aspect('equal')
plt.show()
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Mesh\meshtools.py:7299: RuntimeWarning: divide by zero encountered in divide
  Mx = np.reshape(np.vstack([e1[:,1],e2[:,1],e3[:,1]])/area /2. , ((1, 3*nf)), order='F');
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Mesh\meshtools.py:7299: RuntimeWarning: invalid value encountered in divide
  Mx = np.reshape(np.vstack([e1[:,1],e2[:,1],e3[:,1]])/area /2. , ((1, 3*nf)), order='F');
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Mesh\meshtools.py:7300: RuntimeWarning: divide by zero encountered in divide
  My = -np.reshape(np.vstack([e1[:,0],e2[:,0],e3[:,0]])/area /2. , ((1, 3*nf)), order='F');
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Mesh\meshtools.py:7300: RuntimeWarning: invalid value encountered in divide
  My = -np.reshape(np.vstack([e1[:,0],e2[:,0],e3[:,0]])/area /2. , ((1, 3*nf)), order='F');
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\unwrap3D\Mesh\meshtools.py:7324: RuntimeWarning: invalid value encountered in divide
  mu = (E - G + 2 * 1j * F) / ((E + G + 2.*np.sqrt(E*G - F**2))+1e-12);
c:\Users\s205272\AppData\Local\miniconda3\envs\unwrap3d_test_py310_noskfmm\lib\site-packages\scipy\optimize\_optimize.py:2359: OptimizeWarning: 
NaN result encountered.
  _endprint(x, flag, fval, maxfun, xatol, disp)
optimal length of y-axis as fraction of x-axis:  1.9098300562505255
../_images/78ed0ff513ff0f8bb98249f99428b4309c5812e2b3e87410bf398b8f845a3509.png