Matplotlib renderiza el modelo 3D [Wavefront .OBJ]

Insertar descripción de la imagen aquí

Recomendado: utilice el editor NSDT para crear rápidamente escenas 3D programables

Matplotlib tiene una interfaz 3D muy agradable con muchas características (y algunas limitaciones) y es muy popular entre los usuarios. Sin embargo, para algunos usuarios (o quizás para la mayoría de los usuarios) el 3D todavía se considera una especie de magia negra. Por eso, quiero explicar en este artículo que el renderizado 3D se vuelve muy fácil una vez que comprendes algunos conceptos. Para demostrar esto, usaremos 60 líneas de código Python y una llamada a Matplotlib para representar el conejito de arriba sin usar ejes 3D.

Si el modelo que tiene no está en formato .OBJ, puede utilizar NSDT 3DConvert, una herramienta de conversión de formato 3D en línea, para convertirlo al formato .OBJ :
Insertar descripción de la imagen aquí

1. Carga el conejo

Primero, necesitamos cargar el modelo. Usaremos una versión simplificada del Stanford Rabbit. El archivo usa el formato wavefront .ob, que es uno de los formatos más simples, así que hagamos un cargador muy simple (pero propenso a errores) que hará el trabajo para este artículo (y este modelo):

V, F = [], []
with open("bunny.obj") as f:
   for line in f.readlines():
       if line.startswith('#'):
           continue
       values = line.split()
       if not values:
           continue
       if values[0] == 'v':
           V.append([float(x) for x in values[1:4]])
       elif values[0] == 'f':
           F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1

V es ahora un conjunto de vértices (puntos 3D si lo prefiere) y F es un conjunto de caras (=triángulos). Cada triángulo está descrito por 3 índices relativos a la matriz de vértices. Ahora, normalicemos los vértices para que todo el conejito quepa en la caja unitaria:

V = (V-(V.max(0)+V.min(0))/2)/max(V.max(0)-V.min(0))

Ahora podemos echar un primer vistazo al modelo obteniendo solo las coordenadas x,y de los vértices y eliminando la coordenada z. Para hacer esto, podemos usar el poderoso objeto PolyCollection, que puede representar de manera eficiente colecciones de polígonos irregulares. Como queremos renderizar un montón de triángulos, esta es una combinación perfecta. Por lo tanto, primero extraemos el triángulo y eliminamos la coordenada z:

T = V[F][...,:2]

Ahora podemos renderizarlo:

fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1],
                  aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1,
                            facecolor="None", edgecolor="black")
ax.add_collection(collection)
plt.show()

Deberías obtener algo como esto (bunny-1.py):

Insertar descripción de la imagen aquí

2. Proyección en perspectiva

La representación que acabamos de hacer es en realidad una proyección ortográfica, mientras que el conejito de arriba usa una proyección en perspectiva:
Insertar descripción de la imagen aquí

En ambos casos, la forma correcta de definir la proyección es definir primero el volumen de visualización, es decir, el volumen en el espacio 3D que queremos renderizar en pantalla. Para hacer esto, debemos considerar 6 planos de recorte (izquierda, derecha, arriba, abajo, lejos, cerca) que encierran el volumen de visualización (vista frustum) en relación con la cámara. Si definimos la posición de la cámara y la dirección de visión, cada plano puede describirse mediante un único escalar. Una vez que tengamos este volumen de visualización, podremos proyectar en la pantalla mediante proyección ortográfica o en perspectiva.

Afortunadamente para nosotros, estas proyecciones son bien conocidas y se pueden representar mediante una matriz de 4x4:

def frustum(left, right, bottom, top, znear, zfar):
    M = np.zeros((4, 4), dtype=np.float32)
    M[0, 0] = +2.0 * znear / (right - left)
    M[1, 1] = +2.0 * znear / (top - bottom)
    M[2, 2] = -(zfar + znear) / (zfar - znear)
    M[0, 2] = (right + left) / (right - left)
    M[2, 1] = (top + bottom) / (top - bottom)
    M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
    M[3, 2] = -1.0
    return M

def perspective(fovy, aspect, znear, zfar):
    h = np.tan(0.5*radians(fovy)) * znear
    w = h * aspect
    return frustum(-w, w, -h, h, znear, zfar)

Para la proyección en perspectiva también necesitamos especificar el ángulo de apertura que (más o menos) establece el tamaño del plano cercano en relación con el plano lejano. Entonces, con aperturas altas se obtiene mucha "distorsión".

Sin embargo, si observa las dos funciones anteriores, verá que devuelven matrices de 4x4 y nuestras coordenadas están en 3D. Entonces, ¿cómo utilizar estas matrices? La respuesta son coordenadas homogéneas. En pocas palabras, las coordenadas homogéneas son las más adecuadas para manejar transformaciones y proyecciones en 3D. En nuestro caso, dado que estamos tratando con vértices (en lugar de vectores), simplemente sumamos 1 como cuarta coordenada (w) a todos los vértices. Luego podemos aplicar una transformación de perspectiva utilizando el producto escalar.

V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T

Como paso final, necesitamos volver a normalizar las coordenadas homogéneas. Esto significa que dividimos cada vértice transformado por el último componente (w) de modo que cada vértice siempre tenga w=1.

V /= V[:,3].reshape(-1,1)

Ahora podemos mostrar los resultados nuevamente (bunny-2.py):
Insertar descripción de la imagen aquí

Oh, resultados extraños. ¿qué pasó? El problema es que la cámara en realidad está dentro del conejo. Para obtener la representación correcta, debemos alejar el conejito de la cámara o alejar la cámara del conejito. Hagamos lo siguiente. La cámara está actualmente en (0,0,0) y mira hacia arriba en la dirección z (debido a la transformación frustum). Por lo tanto, necesitamos alejar ligeramente la cámara en la dirección z negativa antes de la transformación de perspectiva:

V = V - (0,0,3.5)
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
V /= V[:,3].reshape(-1,1)

Ahora deberías obtener (bunny-3.py):
Insertar descripción de la imagen aquí

3. Modelo, vista, proyección (MVP)

Puede que no sea obvio, pero el render final es en realidad una transformación de perspectiva. Para hacerlo más visible, rotaremos el conejito. Para esto necesitamos alguna matriz de rotación (4x4), y también podemos definir una matriz de traslación:

def translate(x, y, z):
    return np.array([[1, 0, 0, x],
                     [0, 1, 0, y],
                     [0, 0, 1, z],
                     [0, 0, 0, 1]], dtype=float)

def xrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return np.array([[1, 0,  0, 0],
                     [0, c, -s, 0],
                     [0, s,  c, 0],
                     [0, 0,  0, 1]], dtype=float)

def yrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return  np.array([[ c, 0, s, 0],
                      [ 0, 1, 0, 0],
                      [-s, 0, c, 0],
                      [ 0, 0, 0, 1]], dtype=float)

Ahora desglosaremos las transformaciones a aplicar en términos de modelo (transformación local), vista (transformación global) y proyección para poder calcular una matriz MVP global que lo haga todo al mismo tiempo:

model = xrotate(20) @ yrotate(45)
view  = translate(0,0,-3.5)
proj  = perspective(25, 1, 1, 100)
MVP   = proj  @ view  @ model

Ahora escribimos:

V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)

Deberías obtener (bunny-4.py):

Insertar descripción de la imagen aquí

Ahora ajustemos un poco la apertura para que puedas ver la diferencia. Tenga en cuenta que también tenemos que ajustar la distancia a la cámara para que el conejito tenga el mismo tamaño aparente (bunny-5.py):
Insertar descripción de la imagen aquí

4. Clasificación en profundidad

Ahora intentemos llenar el triángulo (bunny-6.py)
Insertar descripción de la imagen aquí

Como puede ver, los resultados son "interesantes" y completamente erróneos. El problema es que PolyCollection dibuja los triángulos en el orden indicado y queremos dibujar los triángulos de atrás hacia adelante. Esto significa que debemos ordenarlos según su profundidad. La buena noticia es que cuando aplicamos la transformación MVP, ya calculamos esta información. Se almacena en la nueva coordenada z. Sin embargo, estos valores z se basan en vértices y necesitamos ordenar los triángulos. Por lo tanto, tomamos el valor z promedio como indicador de la profundidad del triángulo. Esto funciona bien si los triángulos son relativamente pequeños y disjuntos:

T =  V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
I = np.argsort(Z)
T = T[I,:]

Ahora todo se muestra correctamente (bunny-7.py):
Insertar descripción de la imagen aquí

Agreguemos algo de color usando el búfer de profundidad. Colorearemos cada triángulo según su profundidad. La belleza de los objetos PolyCollection es que puedes especificar el color de cada triángulo usando una matriz NumPy, así que hagamos esto:

zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]

Ahora todo se muestra correctamente (bunny-8.py):
Insertar descripción de la imagen aquí

El guión final tiene 57 líneas (pero es difícil de leer):

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import PolyCollection

def frustum(left, right, bottom, top, znear, zfar):
    M = np.zeros((4, 4), dtype=np.float32)
    M[0, 0] = +2.0 * znear / (right - left)
    M[1, 1] = +2.0 * znear / (top - bottom)
    M[2, 2] = -(zfar + znear) / (zfar - znear)
    M[0, 2] = (right + left) / (right - left)
    M[2, 1] = (top + bottom) / (top - bottom)
    M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
    M[3, 2] = -1.0
    return M
def perspective(fovy, aspect, znear, zfar):
    h = np.tan(0.5*np.radians(fovy)) * znear
    w = h * aspect
    return frustum(-w, w, -h, h, znear, zfar)
def translate(x, y, z):
    return np.array([[1, 0, 0, x], [0, 1, 0, y],
                     [0, 0, 1, z], [0, 0, 0, 1]], dtype=float)
def xrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return np.array([[1, 0,  0, 0], [0, c, -s, 0],
                     [0, s,  c, 0], [0, 0,  0, 1]], dtype=float)
def yrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return  np.array([[ c, 0, s, 0], [ 0, 1, 0, 0],
                      [-s, 0, c, 0], [ 0, 0, 0, 1]], dtype=float)
V, F = [], []
with open("bunny.obj") as f:
    for line in f.readlines():
        if line.startswith('#'):  continue
        values = line.split()
        if not values:            continue
        if values[0] == 'v':      V.append([float(x) for x in values[1:4]])
        elif values[0] == 'f' :   F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V = (V-(V.max(0)+V.min(0))/2) / max(V.max(0)-V.min(0))
MVP = perspective(25,1,1,100) @ translate(0,0,-3.5) @ xrotate(20) @ yrotate(45)
V = np.c_[V, np.ones(len(V))]  @ MVP.T
V /= V[:,3].reshape(-1,1)
V = V[F]
T =  V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1], aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1, facecolor=C, edgecolor="black")
ax.add_collection(collection)
plt.show()

Enlace original: Matplotlib renderiza modelos 3D: BimAnt

Supongo que te gusta

Origin blog.csdn.net/shebao3333/article/details/132826064
Recomendado
Clasificación