Skip to content

Commit 92bfadf

Browse files
dylanebertpcuenca
andauthored
Add vertex color to textured mesh tutorial (#2374)
* add vertex color to textured mesh tutorial * Update vertex-colored-to-textured-mesh.md Co-authored-by: Pedro Cuenca <[email protected]> * Update vertex-colored-to-textured-mesh.md Co-authored-by: Pedro Cuenca <[email protected]> * Update vertex-colored-to-textured-mesh.md Co-authored-by: Pedro Cuenca <[email protected]> * address pedro suggestions * update release date * fix typo * add doc-images screenshots --------- Co-authored-by: Pedro Cuenca <[email protected]>
1 parent c8d6440 commit 92bfadf

File tree

3 files changed

+295
-1
lines changed

3 files changed

+295
-1
lines changed

_blog.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4698,4 +4698,15 @@
46984698
- on-device
46994699
- llm
47004700
- nlp
4701-
- vision
4701+
- vision
4702+
4703+
- local: vertex-colored-to-textured-mesh
4704+
title: "Converting Vertex-Colored Meshes to Textured Meshes"
4705+
author: dylanebert
4706+
thumbnail: /blog/assets/vertex-colored-to-textured-mesh/thumbnail.png
4707+
date: September 30, 2024
4708+
tags:
4709+
- vision
4710+
- 3d
4711+
- mesh
4712+
- tutorial
767 KB
Loading

vertex-colored-to-textured-mesh.md

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# Converting Vertex-Colored Meshes to Textured Meshes
2+
3+
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://githubtocolab.com/dylanebert/InstantTexture/blob/main/notebooks/walkthrough.ipynb)
4+
5+
Convert vertex-colored meshes to UV-mapped, textured meshes.
6+
7+
<gradio-app theme_mode="light" space="dylanebert/InstantTexture"></gradio-app>
8+
9+
## Introduction
10+
11+
Vertex colors are a straightforward way to add color information directly to a mesh's vertices. This is often the way generative 3D models like [InstantMesh](https://huggingface.co/spaces/TencentARC/InstantMesh) produce meshes. However, most applications prefer UV-mapped, textured meshes.
12+
13+
This tutorial walks through a quick solution to convert vertex-colored meshes to UV-mapped, textured meshes. This includes [The Short Version](#the-short-version) to get results quickly, and [The Long Version](#the-long-version) for an in-depth walkthrough.
14+
15+
## The Short Version
16+
17+
Install the [InstantTexture](https://github.com/dylanebert/InstantTexture) library for easy conversion. This is a small library we wrote that implements the steps described in [The Long Version](#the-long-version) below.
18+
19+
```bash
20+
pip install git+https://github.com/dylanebert/InstantTexture
21+
```
22+
23+
### Usage
24+
25+
The code below converts a vertex-colored `.obj` mesh to a UV-mapped, textured `.glb` mesh and saves it to `output.glb`.
26+
27+
```python
28+
from instant_texture import Converter
29+
30+
input_mesh_path = "https://raw.githubusercontent.com/dylanebert/InstantTexture/refs/heads/main/examples/chair.obj"
31+
32+
converter = Converter()
33+
converter.convert(input_mesh_path)
34+
```
35+
36+
Let's visualize the output mesh.
37+
38+
```python
39+
import trimesh
40+
41+
mesh = trimesh.load("output.glb")
42+
mesh.show()
43+
```
44+
45+
That's it!
46+
47+
For a detailed walkthrough, continue reading.
48+
49+
## The Long Version
50+
51+
Install the following dependencies:
52+
53+
- **numpy** for numerical operations
54+
- **trimesh** for loading and saving mesh data
55+
- **xatlas** for generating uv maps
56+
- **Pillow** for image processing
57+
- **opencv-python** for image processing
58+
- **httpx** for downloading the input mesh
59+
60+
```bash
61+
pip install numpy trimesh xatlas opencv-python pillow httpx
62+
```
63+
64+
Import dependencies.
65+
66+
```python
67+
import cv2
68+
import numpy as np
69+
import trimesh
70+
import xatlas
71+
from PIL import Image, ImageFilter
72+
```
73+
74+
Load the vertex-colored input mesh. This should be a `.obj` file located at `input_mesh_path`.
75+
76+
If it's a local file, use `trimesh.load()` instead of `trimesh.load_remote()`.
77+
78+
```python
79+
mesh = trimesh.load_remote(input_mesh_path)
80+
mesh.show()
81+
```
82+
83+
Access the vertex colors of the mesh.
84+
85+
If this fails, ensure the mesh is a valid `.obj` file with vertex colors.
86+
87+
```python
88+
vertex_colors = mesh.visual.vertex_colors
89+
```
90+
91+
Generate the uv map using xatlas.
92+
93+
This is the most time-consuming part of the process.
94+
95+
```python
96+
vmapping, indices, uvs = xatlas.parametrize(mesh.vertices, mesh.faces)
97+
```
98+
99+
Remap the vertices and vertex colors to the uv map.
100+
101+
```python
102+
vertices = mesh.vertices[vmapping]
103+
vertex_colors = vertex_colors[vmapping]
104+
105+
mesh.vertices = vertices
106+
mesh.faces = indices
107+
```
108+
109+
Define the desired texture size.
110+
111+
Construct a texture buffer that is upscaled by an `upscale_factor` to create a higher quality texture.
112+
113+
```python
114+
texture_size = 1024
115+
116+
upscale_factor = 2
117+
buffer_size = texture_size * upscale_factor
118+
119+
texture_buffer = np.zeros((buffer_size, buffer_size, 4), dtype=np.uint8)
120+
```
121+
122+
Fill in the texture of the UV-mapped mesh using barycentric interpolation.
123+
124+
1. **Barycentric interpolation**: Computes the interpolated color at point `p` inside a triangle defined by vertices `v0`, `v1`, and `v2` with corresponding colors `c0`, `c1`, and `c2`.
125+
2. **Point-in-Triangle test**: Determines if a point `p` lies within a triangle defined by vertices `v0`, `v1`, and `v2`.
126+
3. **Texture-filling loop**:
127+
- Iterate over each face of the mesh.
128+
- Retrieve the UV coordinates (`uv0`, `uv1`, `uv2`) and colors (`c0`, `c1`, `c2`) for the current face.
129+
- Convert the UV coordinates to buffer coordinates.
130+
- Determine the bounding box of the triangle on the texture buffer.
131+
- For each pixel in the bounding box, check if the pixel lies within the triangle using the point-in-triangle test.
132+
- If inside, compute the interpolated color using barycentric interpolation.
133+
- Assign the color to the corresponding pixel in the texture buffer.
134+
135+
```python
136+
# Barycentric interpolation
137+
def barycentric_interpolate(v0, v1, v2, c0, c1, c2, p):
138+
v0v1 = v1 - v0
139+
v0v2 = v2 - v0
140+
v0p = p - v0
141+
d00 = np.dot(v0v1, v0v1)
142+
d01 = np.dot(v0v1, v0v2)
143+
d11 = np.dot(v0v2, v0v2)
144+
d20 = np.dot(v0p, v0v1)
145+
d21 = np.dot(v0p, v0v2)
146+
denom = d00 * d11 - d01 * d01
147+
if abs(denom) < 1e-8:
148+
return (c0 + c1 + c2) / 3
149+
v = (d11 * d20 - d01 * d21) / denom
150+
w = (d00 * d21 - d01 * d20) / denom
151+
u = 1.0 - v - w
152+
u = np.clip(u, 0, 1)
153+
v = np.clip(v, 0, 1)
154+
w = np.clip(w, 0, 1)
155+
interpolate_color = u * c0 + v * c1 + w * c2
156+
return np.clip(interpolate_color, 0, 255)
157+
158+
159+
# Point-in-Triangle test
160+
def is_point_in_triangle(p, v0, v1, v2):
161+
def sign(p1, p2, p3):
162+
return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1])
163+
164+
d1 = sign(p, v0, v1)
165+
d2 = sign(p, v1, v2)
166+
d3 = sign(p, v2, v0)
167+
168+
has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0)
169+
has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0)
170+
171+
return not (has_neg and has_pos)
172+
173+
# Texture-filling loop
174+
for face in mesh.faces:
175+
uv0, uv1, uv2 = uvs[face]
176+
c0, c1, c2 = vertex_colors[face]
177+
178+
uv0 = (uv0 * (buffer_size - 1)).astype(int)
179+
uv1 = (uv1 * (buffer_size - 1)).astype(int)
180+
uv2 = (uv2 * (buffer_size - 1)).astype(int)
181+
182+
min_x = max(int(np.floor(min(uv0[0], uv1[0], uv2[0]))), 0)
183+
max_x = min(int(np.ceil(max(uv0[0], uv1[0], uv2[0]))), buffer_size - 1)
184+
min_y = max(int(np.floor(min(uv0[1], uv1[1], uv2[1]))), 0)
185+
max_y = min(int(np.ceil(max(uv0[1], uv1[1], uv2[1]))), buffer_size - 1)
186+
187+
for y in range(min_y, max_y + 1):
188+
for x in range(min_x, max_x + 1):
189+
p = np.array([x + 0.5, y + 0.5])
190+
if is_point_in_triangle(p, uv0, uv1, uv2):
191+
color = barycentric_interpolate(uv0, uv1, uv2, c0, c1, c2, p)
192+
texture_buffer[y, x] = np.clip(color, 0, 255).astype(
193+
np.uint8
194+
)
195+
```
196+
197+
Let's visualize how the texture looks so far.
198+
199+
```python
200+
from IPython.display import display
201+
202+
image_texture = Image.fromarray(texture_buffer)
203+
display(image_texture)
204+
```
205+
206+
![Texture with holes](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/vertex-colored-to-textured-mesh/tex_output_1.png)
207+
208+
As we can see, the texture has a lot of holes.
209+
210+
To correct for this, we'll combine 4 techniques:
211+
212+
1. **Inpainting**: Fill in the holes using the average color of the surrounding pixels.
213+
2. **Median filter**: Remove noise by replacing each pixel with the median color of its surrounding pixels.
214+
3. **Gaussian blur**: Smooth out the texture to remove any remaining noise.
215+
4. **Downsample**: Resize down to `texture_size` with LANCZOS resampling.
216+
217+
```python
218+
# Inpainting
219+
image_bgra = texture_buffer.copy()
220+
mask = (image_bgra[:, :, 3] == 0).astype(np.uint8) * 255
221+
image_bgr = cv2.cvtColor(image_bgra, cv2.COLOR_BGRA2BGR)
222+
inpainted_bgr = cv2.inpaint(
223+
image_bgr, mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA
224+
)
225+
inpainted_bgra = cv2.cvtColor(inpainted_bgr, cv2.COLOR_BGR2BGRA)
226+
texture_buffer = inpainted_bgra[::-1]
227+
image_texture = Image.fromarray(texture_buffer)
228+
229+
# Median filter
230+
image_texture = image_texture.filter(ImageFilter.MedianFilter(size=3))
231+
232+
# Gaussian blur
233+
image_texture = image_texture.filter(ImageFilter.GaussianBlur(radius=1))
234+
235+
# Downsample
236+
image_texture = image_texture.resize((texture_size, texture_size), Image.LANCZOS)
237+
238+
# Display the final texture
239+
display(image_texture)
240+
```
241+
242+
![Texture without holes](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/vertex-colored-to-textured-mesh/tex_output_2.png)
243+
244+
As we can see, the texture is now much smoother and has no holes.
245+
246+
This can be further improved with more advanced techniques or manual texture editing.
247+
248+
Finally, we can construct a new mesh with the generated uv coordinates and texture.
249+
250+
```python
251+
material = trimesh.visual.material.PBRMaterial(
252+
baseColorFactor=[1.0, 1.0, 1.0, 1.0],
253+
baseColorTexture=image_texture,
254+
metallicFactor=0.0,
255+
roughnessFactor=1.0,
256+
)
257+
258+
visuals = trimesh.visual.TextureVisuals(uv=uvs, material=material)
259+
mesh.visual = visuals
260+
mesh.show()
261+
```
262+
263+
![Final mesh](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/vertex-colored-to-textured-mesh/mesh_output.png)
264+
265+
Et voilà! The mesh is UV-mapped and textured.
266+
267+
To export it when running locally, call `mesh.export("output.glb")`.
268+
269+
## Limitations
270+
271+
As you can see, the mesh still has many small artifacts.
272+
273+
The quality of the UV map and texture are also far below the standards of a production-ready mesh.
274+
275+
However, if you're looking for a quick solution to map from a vertex-colored mesh to a UV-mapped mesh, then this approach may be useful for you.
276+
277+
## Conclusion
278+
279+
This tutorial walked through how to convert a vertex-colored mesh to a UV-mapped, textured mesh.
280+
281+
If you have any questions or feedback, please feel free to open an issue on [GitHub](https://github.com/dylanebert/InstantTexture) or on the [Space](https://huggingface.co/spaces/dylanebert/InstantTexture).
282+
283+
Thank you for reading!

0 commit comments

Comments
 (0)