Texture Mapping a Torus

Texture mapping is the process of applying a 2D image (the “texture”) to a 3D surface.

Below we apply a texture to a torus mesh using Open3D. The texture will be generated by applying a linear map (e.g., the cat map, Dehn twist) to an image, and then we will calculate the UV coordinates for the torus mesh to ensure that the texture wraps around it correctly. Finally, we will visualize the textured torus and export it as an OBJ file that can be imported into other 3D software like Blender.

Packages & Modules

# main 3d viewing and mesh manipulation library
import open3d as o3d
import open3d.visualization.rendering as rendering

# for image manipulation
import numpy as np
from numpy import pi, sin, cos
import skimage.io as sio
import skimage.transform as st
from skimage import io, img_as_ubyte

# plotting
import matplotlib.pyplot as plt
import sys, os # for importing my module
sys.path.append(os.path.abspath("../../"))# my module for applying the map to the image and coordinates
import apply_image_map
import importlib # for reloading modules during development
importlib.reload(apply_image_map)
<module 'apply_image_map' from '/Users/josh/Google Drive/Research/apply_image_map.py'>

– – M A T E R I A L – –

# the material record is where we specify the texture and shader for the mesh
material = rendering.MaterialRecord()
material.shader = 'defaultUnlit'

– – I M A G E – S E T U P – –

#
# filename = "octotour-24.png"
filename = "uv_checkerboard"
# filename = "cat_collage"
# filename = 'swirled_checkerboard_coolest'
# filename = 'uv_cats'
#
ext = ".png"
my_dir = "../../"
main_image = my_dir + filename + ext # join image & extension

# read in the image using skimage
img = sio.imread(main_image)

# resize using skimage
img_res = 1024
output_shape = (img_res,img_res,img.shape[2])
img = st.resize(img,output_shape = output_shape,anti_aliasing=True)

– M A P P I N G – M A T R I X – –

#
# -- the cat map
# map_name = "dehn_twist"
# M = np.array([[1,1],[1,2]])
#
# -- the identity map
# map_name = "identity"
# M = np.array([[1,0],[0,1]])
#
# -- dehn twist
# map_name = "dehn_twist"
#M = np.array([[1,1],[0,1]])
#
# -- period 3 map
map_name = "period_3_map"
M = np.array([[0,1],[-1,-1]])
#
print(f'\n --- {map_name} --- \n')

 --- period_3_map --- 

– – M A P – S E T T I N G S – –

# 
#        iterations of the map 
#
# 30 iterations of the cat map makes a good picture, 
# can see the mixing but also open/continuity 
# 192 is the period for 256x256 image
# the period for 512x512 image is 384?
# the period for 800x800 image is 600?
# the period for 1024x1024 image is 768?
# num_its = 96 # half period
# num_its = 48 # quarter period
# num_its = 24 # eighth period
# num_its = 192 # sixteenth period
num_its = 1 # identity map, no iterations

– – G R I D – S E T U P – –

# 
#       xx,yy are the pixel coordinates
#
# notice the -1 below to keep integer valued xx,yy
# with kw-1 below, the spacing is 1 pixel apart
kw = output_shape[0]
kh = output_shape[1]
xgrid = np.linspace(0,kw-1,kw)
ygrid = np.linspace(0,kh-1,kh)
col,row = np.meshgrid(xgrid,ygrid)

– – A P P L Y – M A P – –

# 
#     apply the map num_its times
# 
# placeholders for iterated image and coordinates
img_after = img

# resolution is much better if we mod first, which in general is not the same
# but works in this case (linear map, integer entries, square image) because the map is periodic and the period divides the resolution)  

# in case we want to iterate the map and compare
#for i in range(0,num_its): 
MM = np.mod(np.linalg.matrix_power(M,num_its), img_res)
X,Y,Z,img_after,col,row = apply_image_map.mod(img.shape[0],MM,img_after,col,row,3)

# set the material image to the final mapped image
material.albedo_img = o3d.geometry.Image((img_after*255).astype(np.uint8))

## apply mapping to image without mod
#I = img_as_ubyte(img)
#for i in range(0,3):
#    im = apply_image_map.no_mod(I,np.linalg.matrix_power(M,i))
#    plt.figure()
#    plt.imshow(im)
#    plt.title(f'Image after {i} iterations')
#    plt.axis('off')
#    plt.show()
#    sio.imsave(f"torus/assets/flat_img_{i}.png",im)

– – V I S U A L I Z E – M A P P E D – I M A G E – –

Image of the initial image under the transformation:

new_pixel_coords = matrix * old_pixel_coords (mod image_resolution)

where the matrix is \[M = \begin{bmatrix} 0 & 1 \\ -1 & -1 \end{bmatrix}\]

plt.imshow(img_after)

Everything above is work done on a square image that we will use as a texture. Below we will set up the torus mesh and calculate the UV coordinates to apply this texture to the torus.

– – T O R U S – M E S H – S E T U P – –

mesh = o3d.geometry.TriangleMesh.create_torus()
# R is the (default) major radius (distance from center of donut to center of tube)
R = 1.0 

– L E A R N H O W V E R T I C E S M A P T O T R I A N G L E S – – –

These lines help you understand how the vertices of the mesh are connected to the trianlges.

# assign the texture to the mesh using the triangles of the mesh
triangles = np.asarray(mesh.triangles)

# The vertex index you are looking for (e.g., the 10th vertex)
target_v_idx = 0

# 1. Find the boolean mask where the index exists in any of the 3 columns
mask = np.any(triangles == target_v_idx, axis=1)

# 2. Get the actual indices of those triangles
sharing_triangles_indices = np.where(mask)[0]

print(f"Vertex {target_v_idx} is shared by triangles: {sharing_triangles_indices}")

# 3. Optional: Get the actual vertex IDs of those neighboring triangles
neighbor_triangles = triangles[sharing_triangles_indices]
print("These triangles connect these vertices:\n", neighbor_triangles)
Vertex 0 is shared by triangles: [   0    1   39 1160 1198 1199]
These triangles connect these vertices:
 [[ 20  21   0]
 [  0  21   1]
 [ 19  20   0]
 [  0   1 580]
 [ 19   0 599]
 [599   0 580]]

– – – C A L C U L A T E U V S F O R T O R U S M E S H – – –

verts = np.asarray(mesh.vertices)
 
# Distance from the center of the donut in the XY plane
distance_xy = np.sqrt(verts[:, 0]**2 + verts[:, 1]**2)

# 1. Clean angles (No manual seam_ix shift needed anymore)
u_angle = np.arctan2(verts[:, 1], verts[:, 0])
v_angle = np.arctan2(verts[:, 2], np.sqrt(verts[:, 0]**2 + verts[:, 1]**2) - R)

# 2. Normalize to 0-1
u_norm = (u_angle + np.pi) / (2 * np.pi)
v_norm = (v_angle + np.pi) / (2 * np.pi)

# when the u_norm coordinates decrease, shift them up by 1 to prevent large jumps
# the u coordinates should be monotonically increasing
u_diff = np.diff(u_norm, prepend=u_norm[0])
decreasing_mask = u_diff < 0
first_drop = np.where(decreasing_mask)[0][0]
u_norm[first_drop:] += 1.0

3. Create initial UVs

v_uvs = np.stack([u_norm, v_norm], axis=1)
triangles = np.asarray(mesh.triangles)
final_uvs = v_uvs[triangles.flatten()].reshape(-1, 3, 2)
# 4. The Snapping Loop
for i in range(len(final_uvs)):
    for d in range(2): # 0 = U, 1 = V
        col = final_uvs[i, :, d]
        # Calculate the internal spread of the triangle
        if np.ptp(col) > 0.8: # ptp is "peak to peak" (max - min)
            # Find the "low" points (near 0) and shift them to the "high" side (near 1)
            # This makes the triangle sit entirely on the right/bottom edge of the map
            low_mask = col < 0.2
            final_uvs[i, low_mask, d] += 1.0
            
        # EXTRA STEP: Snap values very close to 0 or 1 to exactly 0 or 1
        # This prevents the "sub-pixel" crack you are seeing
        final_uvs[i, :, d] = np.where(np.abs(final_uvs[i, :, d] - 1.0) < 1e-4, 1.0, final_uvs[i, :, d])
        final_uvs[i, :, d] = np.where(np.abs(final_uvs[i, :, d] - 0.0) < 1e-7, 0.0, final_uvs[i, :, d])

# 5. Apply
mesh.triangle_uvs = o3d.utility.Vector2dVector(final_uvs.reshape(-1, 2))

mesh.remove_duplicated_vertices()
mesh.compute_vertex_normals()

# Flatten back for Open3D
mesh.triangle_uvs = o3d.utility.Vector2dVector(final_uvs.reshape(-1, 2))

normalize

uvs = np.asarray(mesh.triangle_uvs)
# normalize uvs to [0,1]
uvs_min = uvs.min(axis=0)
uvs_max = uvs.max(axis=0)
uvs_range = uvs_max - uvs_min
uvs_normalized = (uvs - uvs_min) / uvs_range

mesh.triangle_uvs = o3d.utility.Vector2dVector(uvs_normalized)

uvs = np.asarray(mesh.triangle_uvs)


# Gently squeeze the U coordinates to prevent the edge-wrap overlap
uvs[:, 0] = uvs[:, 0] * 0.99
mesh.triangle_uvs = o3d.utility.Vector2dVector(uvs)

– – – F I X S E A M C R O S S I N G T R I A N G L E S – – –

# 
#                     attempt to fix seam wrapping

# Get the UVs as a numpy array
uvs = np.asarray(mesh.triangle_uvs)

# Iterate through the array in jumps of 3 (each triangle)
a = 0
for i in range(0, len(uvs), 3):
    tri_uvs = uvs[i:i+3]

    # Check the horizontal (U) spread of this triangle
    u_min = np.min(tri_uvs[:, 0])
    u_max = np.max(tri_uvs[:, 0])

    # If the spread is huge (e.g., > 0.8), it's a "Seam Crosser"
    if (u_max - u_min) > 0.8:
        # For any corner in THIS triangle that is near 0, make it 1
#        print('Seam crosser triangle detected at index:', i // 3)
        a = 1

        for j in range(3):
            if uvs[i + j, 0] < 0.2:
                uvs[i + j, 0] += 1.0

# update the mesh UVs
mesh.triangle_uvs = o3d.utility.Vector2dVector(uvs)

# ensure the mesh has normals (crucial for it to look 3D in viewers)
mesh.compute_vertex_normals()
TriangleMesh with 600 points and 1200 triangles.

– – – A D D R E D D O T S A T F I X E D P O I N T S – – –

# Open3D textures are stored as o3d.geometry.Image

# 3. Define your points and dot size
points = [[0, 0], [img_res//3, img_res//3], [2*img_res//3, 2*img_res//3]]
dot_radius = [10, 8, 6] # radius in pixels
dot_color = np.zeros((3, 3)) # 3 colors for 3 points
dot_color[0] = [1, 0, 0]  # Bright Red
dot_color[1] = [0, 0, 0]  # Bright Red
dot_color[2] = [1, 1, 1]  # Bright Red

# loop through 3 colors with different dot_radii
# and then through points and "paint" the array
for j in range(3):
    for y, x in points:
        # We use a slice to make a small square so the dot is visible
        # We use np.clip to ensure we don't go outside the 1024 boundary
        y_min, y_max = max(0, y-dot_radius[j]), min(1023, y+dot_radius[j])
        x_min, x_max = max(0, x-dot_radius[j]), min(1023, x+dot_radius[j])
        
        img_after[y_min:y_max, x_min:x_max,0:3] = dot_color[j]


# 2. Update the Material Record (for the modern 'draw' call)
# You must re-assign the modified image to the material property

material.albedo_img = o3d.geometry.Image((img_after*255).astype(np.uint8))

– – – V I S U A L I Z E T H E T E X T U R E D M E S H – – –

This is how to visualize a texture mesh using Open3D. The material below is created using the rendering.MaterialRecord to specify the shader and texture. This viewer using an active python kernel, so you can interact with the mesh and see the texture applied in real time. You can also rotate the view to see how the texture wraps around the torus. To export the mesh with the texture for viewing outside of Open3D, see the next section below.

o3d.visualization.draw({'name': 'textured_torus', 'geometry': mesh, 'material': material})

– – – E X P O R T T H E M E S H + T E X T U R E – – –

# Open3D uses the filename to determine the format
# This will generate 'anosov.obj' AND 'anosov.mtl'
# 1. Ensure the texture is in the correct format (uint8)
# If it's a numpy array, convert it to o3d Image

# Mirror the image left-to-right
# flipped_texture = np.fliplr(material.albedo_img).astype(np.uint8) 

# mesh.textures = [o3d.geometry.Image(flipped_texture)]

mesh.textures = [material.albedo_img]

# Get the UVs as a numpy array
uvs = np.asarray(mesh.triangle_uvs)

# Flip the V coordinate (the second column)
# uvs[:, 0] is U, uvs[:, 1] is V
uvs[:, 1] = 1.0 - uvs[:, 1]

# Re-assign the flipped UVs back to the mesh
mesh.triangle_uvs = o3d.utility.Vector2dVector(uvs)

# NOW export
o3d.io.write_triangle_mesh("torus_for_blender.obj", mesh, write_triangle_uvs=True)

# FYI - This effectively turns the mesh inside-out
# mesh.triangles = o3d.utility.Vector3iVector(np.asarray(mesh.triangles)[:, [0, 2, 1]])

# Then re-calculate the lighting normals
mesh.compute_vertex_normals()


# 2. Assign Material IDs (This is what triggers the .mtl reference)
# Without this, the exporter thinks there's no material to write!
num_triangles = len(mesh.triangles)
mesh.triangle_material_ids = o3d.utility.IntVector([0] * num_triangles)


o3d.io.write_triangle_mesh(f"{filename}_res_{img_res}_iter_{num_its}_{map_name}.obj", mesh, write_ascii=True, write_triangle_uvs=True)