Skip to content

Generating Spheroidal Meshes in Python


This is the first in a series about using Python and Jupyter notebooks to explore astronomy, diving into the interesting calculations, methods, and visualizations used in this field.

We will begin by defining and visualizing planet-like shapes (spheroids). The code to accompany this tutorial is on github. Below is an example visualization:

An Example UV Spheroid

Loading Utilities

I use plotly and moviepy to create some helpful utilities to plot the 3D meshes defined here, and to stich together renderings of these meshes into .gif files that can be easily embedded into web content.

Create the following directory structure in your project to setup these utilities, with an empty __init__.py file to tell python we are creating a module.

project
└───plot
|   │   __init__.py
|   │   plotly_utils.py
<other project files>

Create plotly_utils.py and populate it with the following contents:

import io 
from PIL import Image
import numpy as np
import plotly.graph_objects as go
import moviepy.editor as mpy
from scipy.spatial.transform import Rotation as R

def show_poly_3d(nodes=None, polys=None, show_nodes=False):
    '''
    Makes a basic invokation of __plot to show a single frame
    '''
    fig = __plot(nodes=nodes, polys=polys, t=0, show_nodes=show_nodes)
    fig.show()
    return

def create_poly_3d_gif(filepath, nodes=None, polys=None, fps=15, duration=8, show_nodes=False):
    '''
    Creates a gif using moviepy from frames built using plotly
    '''

    def make_frame(t):
        return __plotly_fig2array(__plot(nodes=nodes, polys=polys, t=t, fps=fps, duration=duration, show_nodes=show_nodes))

    gif_output_file = filepath
    animation = mpy.VideoClip(make_frame, duration=duration)
    animation.write_gif(gif_output_file, fps=15, loop=0, opt="OptimizePlus", fuzz=10)
    animation.close()

    # forcing garbage collection here seems to account for a 'bug' in moviepy loop count encoding
    import gc
    del animation
    gc.collect()

    return

def __plotly_fig2array(fig):
    '''
    Convert plotly figure into an 2x2 array of RGB bytes
    '''
    fig_bytes = fig.to_image(format="png", scale=1.0, engine='orca')
    buf = io.BytesIO(fig_bytes)
    img = Image.open(buf)
    return np.asarray(img)


def __plot(nodes=None, polys=None, t=0, fps=15, duration=8, show_nodes=False):
    '''
    Creates a plotly figure for a single frame, displaying nodes with scatter3d and
    nodes + polygons with mesh3d
    '''
    assert nodes is not None, "nodes is a required parameter"
    if polys is None:
        show_nodes = True  # always show nodes if there are no polys

    # rotate the polygon on each frame
    frame = t*fps
    max_frame = fps * duration
    p = ((frame - 1) / (max_frame))
    roll = -(2*np.pi/8) * p
    pitch = -np.sin( 2*np.pi * p + 0.321) * (np.pi / 32)
    rotated = R.from_rotvec([pitch, 0.0, roll]).apply(nodes)

    # populate the plot's contents
    data = []
    if polys is not None:
        data.append(__get_mesh3d(rotated, polys))
    if show_nodes:
        data.append(__get_node_scatter3d(rotated))

    # initialize the plot
    fig = go.Figure(data=data)
    fig['layout']['margin'] = dict(l=0, r=0, b=0, t=0)
    fig["layout"]["height"] = 400
    fig["layout"]["width"] = 400

    # configure the plot's axes
    hide_axis_params = {
        'title': '',
        'autorange': False,
        'showgrid': False,
        'zeroline': False,
        'showline': False,
        'ticks': '',
        'showticklabels': False,
        'backgroundcolor': "rgba(0, 0, 0, 0)",
        'showbackground': True,
    }
    fig["layout"]["scene"] = {
      'aspectmode': 'cube',
      'xaxis': {
          **hide_axis_params,
          'range': [np.min(nodes[:,0]), np.max(nodes[:,0])*1.1]
        },
      'yaxis':{
          **hide_axis_params,
          'range': [np.min(nodes[:,1]), np.max(nodes[:,1])*1.1]
       },
      'zaxis': {
          **hide_axis_params,
          'range': [np.min(nodes[:,2]), np.max(nodes[:,2])*1.1]
        }
    }

    # configure the camera to be more zoomed in
    fig["layout"]["scene_camera"] = {
      'up': dict(x=0, y=0, z=0.8),
      'center': dict(x=0, y=0, z=0.0),
      'eye': dict(x=0.8, y=0.8, z=0.8)
    }

    return fig


def __get_mesh3d(nodes, polys, color=None):
    '''
    Constructs a plotly mesh3d object from a list of nodes and polygons
    '''
    i, j, k = zip(*polys)

    # determine colormap settings
    if color is None:
        colormap_settings = {
            'colorscale': [[0, '#010095'], [1, '#1F56FF']],
            'intensity': [c%2 for c in range(len(i))]
        }
    else:
        colormap_settings = {
            'colorscale': [[0, color]],
            'intensity': 0
        }

    return go.Mesh3d(
        **colormap_settings,
        x=nodes[:,0],
        y=nodes[:,1],
        z=nodes[:,2],
        intensitymode='cell',
        flatshading=True,
        i=i,
        j=j,
        k=k,
        showscale=False
    )


def __get_node_scatter3d(nodes):
    '''
    Constructs a plotly scatter3d object from a list of nodes
    '''
    return go.Scatter3d(
        x=nodes[:,0],
        y=nodes[:,1],
        z=nodes[:,2],
        mode ='markers', 
        marker = dict(
            size = 3,
            color = '#CC0000',
            opacity = 0.7
        )
    )

Notebook Setup

Create a file called requirements.txt with the following contents:

moviepy==1.0.3
numpy==1.22.4
Pillow==9.1.1
plotly==5.8.2
scipy==1.8.1
nbformat==5.4.0
decorator==4.4.2
kaleido==0.2.1

Install the dependencies. This examples assumes the usage of miniconda, but you can use your own environment manager to install the dependencies listed above.

Run the following commands:

conda create -n "astro" python=3.9 --file requirements.txt
conda activate astro

Create and start a notebook globe_mesh_uv.ipynb, and activate the astro conda environment.

Inside globe_mesh_uv.ipynb, import the notebook prerequisites. Make sure that you properly added the plotting module mentioned above into the plot/ folder.

import numpy as np
from itertools import product
from plot.plotly_utils import show_poly_3d, create_poly_3d_gif

Define an approximation to the earth’s shape (oblate spheroid)

We will use parameters from the World Geodetic System (WGS 84):

We exaggerate the oblateness of the earth to make the shape more obvious in our visualizations.

OBLATENESS_EXAGGERATION = 1.25

# WGS-84 parameters
A = 6378137 * OBLATENESS_EXAGGERATION  # meters
B = 6356752.3142  # meters

def spheroid(lat, lon):
    '''
    Spheroid surface defined in spherical coordinates (latitude / longitude).
    Returns points on the spheroid in 3D X/Y/Z coordinates
    '''
    return np.array([
        A*np.cos(lat)*np.cos(lon),
        A*np.cos(lat)*np.sin(lon),
        B*np.sin(lat)
    ])

Define the Mesh Nodes

We will combine concepts of a UV Sphere and the Geographic Coordinate System to construct our spheriod mesh.

The mesh nodes are created by sampling spheroid() over a grid of latitude / longitude values.

def uv_sphere_nodes(n_lat=8, n_lon=15):
    nodes = []

    # create the nodes
    latitudes = np.linspace(np.pi/2.0, -np.pi/2.0, num=n_lat, endpoint=True)
    longitudes = np.linspace(-np.pi, np.pi, num=n_lon, endpoint=False)

    # top point
    nodes.append(spheroid(latitudes[0], 0.0))

    # middle points
    for lat, lon in product(latitudes[1:-1], longitudes):
        nodes.append(spheroid(lat, lon))

    # bottom point
    nodes.append(spheroid(latitudes[-1], 0.0))

    return np.array(nodes)

Visualize the Nodes in 3D

The plotting utilities can be used to visualize the nodes.

To show the nodes:

show_poly_3d(nodes=uv_sphere_nodes(n_lat=15, n_lon=60))

To generate a .gif of the nodes with a rotating animation to illustrate the 3d perspective:

create_poly_3d_gif('./image/uv_spheroid_nodes_dense.gif', nodes=uv_sphere_nodes(n_lat=15, n_lon=60), duration=2)

The resulting .gif:

UV Spheriod Nodes

Adding Polygons

Create a low-density set of nodes for illustrative purposes:

N_LAT = 8
N_LON = 15
nodes = uv_sphere_nodes(n_lat=N_LAT, n_lon=N_LON)

The mesh’s polygons are defined by defining connections between groups of three nodes. We will define the UV spheroid from three types of polygon: top, middle, and bottom.

Top-Row Polygons

Define the top polygons by connecting each edge in the first line of latitude with the top node:

top = [[0, j + 1, j + 2] for j in range(N_LON-1)]
top.append([0, N_LON, 1])

show_poly_3d(nodes=nodes, polys=top, show_nodes=True)
Top Row of Polygons

Middle-Row Polygons

Define the middle polygons by subdividing each quadrilateral face between not-terminal parallels:

middle = []
for i in range(1, N_LAT-2):
    for j in range(N_LON - 1):
        middle.append([(i-1)*N_LON + j + 1, i*N_LON + j + 1, i*N_LON + j + 2])
        middle.append([(i-1)*N_LON + j + 1, i*N_LON + j + 2, (i-1)*N_LON + j + 2])       
    middle.append([i*N_LON, (i+1)*N_LON, i*N_LON + 1])
    middle.append([i*N_LON, i*N_LON + 1, (i-1)*N_LON + 1]) 

# plot only every other band
alternating_bands = [band for i, band in enumerate(middle) if (i//(2*N_LON))%2]

show_poly_3d(nodes=nodes, polys=alternating_bands, show_nodes=True)
Middle-Row Polygons

Bottom-Row Polygons

Define the bottom polygons similarly to the top:

bottom = [[(N_LAT-3)*N_LON + j + 1, (N_LAT-2)*N_LON + 1, (N_LAT-3)*N_LON + j + 2] for j in range(N_LON - 1)]
bottom.append([(N_LAT-2)*N_LON, (N_LAT-2)*N_LON + 1, (N_LAT-3)*N_LON + 1])

show_poly_3d(nodes=nodes, polys=bottom, show_nodes=True)
Bottom-Row Polygons

Putting the UV Sphere Together

polys = top + middle + bottom

show_poly_3d(nodes=nodes, polys=polys)
Full UV Spheroid

That’s it! The code to reproduce everything seen in this tutorial can be found on github. Please submit an issue if you have any problems running this code!