Matplotlib визуализирует 3D-модель [Wavefront .OBJ]

Вставьте сюда описание изображения

Рекомендуется: используйте редактор NSDT для быстрого создания программируемых 3D-сцен.

Matplotlib имеет очень приятный 3D-интерфейс со множеством функций (и некоторыми ограничениями) и очень популярен среди пользователей. Однако для некоторых пользователей (а может, и для большинства пользователей) 3D до сих пор считается своего рода черной магией. Итак, в этой статье я хочу объяснить, что 3D-рендеринг становится очень простым, если вы поймете несколько концепций. Чтобы продемонстрировать это, мы будем использовать 60 строк кода Python и один вызов Matplotlib для визуализации кролика, показанного выше, без использования 3D-осей.

Если имеющаяся у вас модель не в формате .OBJ, вы можете использовать NSDT 3DConvert, онлайн-инструмент преобразования формата 3D, чтобы преобразовать ее в формат .OBJ :
Вставьте сюда описание изображения

1. Загрузите кролика

Сначала нам нужно загрузить модель. Мы будем использовать упрощенную версию Стэнфордского кролика. В файле используется формат wavefront .ob, который является одним из самых простых форматов, поэтому давайте создадим очень простой (но подверженный ошибкам) ​​загрузчик, который выполнит работу для этой статьи (и этой модели):

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 теперь представляет собой набор вершин (3D-точек, если хотите), а F — набор граней (=треугольников). Каждый треугольник описывается тремя индексами относительно массива вершин. Теперь давайте нормализуем вершины так, чтобы весь кролик поместился в блок модуля:

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

Теперь мы можем впервые взглянуть на модель, получив только координаты x,y вершин и удалив координату z. Для этого мы можем использовать мощный объект PolyCollection, который может эффективно отображать коллекции неправильных многоугольников. Поскольку мы хотим визуализировать множество треугольников, это идеальное совпадение. Поэтому сначала извлекаем треугольник и удаляем координату z:

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

Теперь мы можем это визуализировать:

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()

У вас должно получиться что-то вроде этого (bunny-1.py):

Вставьте сюда описание изображения

2. Перспективная проекция

Только что созданный рендеринг на самом деле представляет собой ортогональную проекцию, а кролик наверху использует перспективную проекцию:
Вставьте сюда описание изображения

В обоих случаях правильный способ определить проекцию — сначала определить объем просмотра, то есть объем в трехмерном пространстве, который мы хотим визуализировать на экране. Для этого нам нужно рассмотреть 6 плоскостей отсечения (левая, правая, вверх, вниз, дальняя, ближняя), которые ограничивают объем обзора (усеченную пирамиду обзора) относительно камеры. Если мы определим положение камеры и направление обзора, каждую плоскость можно описать одним скаляром. Получив этот объем просмотра, мы можем проецировать его на экран, используя ортогональную или перспективную проекцию.

К счастью для нас, эти прогнозы хорошо известны и могут быть представлены с помощью матрицы 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)

Для перспективной проекции нам также необходимо указать угол апертуры, который (более или менее) задает размер ближней плоскости относительно дальней плоскости. Таким образом, при высокой диафрагме вы получаете много «искажений».

Однако, если вы посмотрите на две функции выше, вы увидите, что они возвращают матрицы 4x4, а наши координаты находятся в 3D. Так как же использовать эти матрицы? Ответ – однородные координаты. Короче говоря, однородные координаты лучше всего подходят для выполнения преобразований и проекций в 3D. В нашем случае, поскольку мы имеем дело с вершинами (а не векторами), мы просто добавляем 1 в качестве четвертой координаты (w) ко всем вершинам. Затем мы можем применить перспективное преобразование, используя скалярное произведение.

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

На последнем этапе нам необходимо перенормировать однородные координаты. Это означает, что мы делим каждую преобразованную вершину на последнюю компоненту (w), чтобы каждая вершина всегда имела w=1.

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

Теперь мы можем снова отобразить результаты (bunny-2.py):
Вставьте сюда описание изображения

Ох, странные результаты. что случилось? Проблема в том, что камера на самом деле находится внутри кролика. Чтобы получить правильный рендеринг, нам нужно отодвинуть кролика от камеры или отодвинуть камеру от кролика. Давайте сделаем следующее. Камера в настоящее время находится в точке (0,0,0) и смотрит вверх в направлении z (из-за преобразования усеченной пирамиды). Поэтому перед преобразованием перспективы нам нужно немного отодвинуть камеру в отрицательном направлении z:

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)

Теперь вы должны получить (bunny-3.py):
Вставьте сюда описание изображения

3. Модель, вид, проекция (MVP)

Это может быть неочевидно, но окончательный рендеринг на самом деле является преобразованием перспективы. Чтобы сделать его более заметным, повернём кролика. Для этого нам понадобится некоторая матрица вращения (4x4), а также мы можем определить матрицу перевода:

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)

Теперь мы разберем преобразования, которые необходимо применить, с точки зрения модели (локальное преобразование), представления (глобальное преобразование) и проекции, чтобы мы могли рассчитать глобальную матрицу MVP, которая может делать все это одновременно:

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

Теперь пишем:

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

Вы должны получить (bunny-4.py):

Вставьте сюда описание изображения

Теперь давайте немного отрегулируем диафрагму, чтобы вы могли увидеть разницу. Обратите внимание, что нам также нужно отрегулировать расстояние от камеры, чтобы кролик имел тот же видимый размер (bunny-5.py):
Вставьте сюда описание изображения

4. Сортировка по глубине

Теперь попробуем заполнить треугольник (bunny-6.py).
Вставьте сюда описание изображения

Как видите, результаты «интересные» и совершенно неправильные. Проблема в том, что PolyCollection рисует треугольники в заданном порядке, а мы хотим рисовать треугольники сзади вперед. Это означает, что нам нужно отсортировать их по глубине. Хорошей новостью является то, что когда мы применяем преобразование MVP, мы уже вычисляем эту информацию. Он сохраняется в новой координате z. Однако эти z-значения основаны на вершинах, и нам нужно отсортировать треугольники. Поэтому мы берем среднее значение z в качестве показателя глубины треугольника. Это хорошо работает, если треугольники относительно маленькие и непересекающиеся:

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

Теперь все отображается правильно (bunny-7.py):
Вставьте сюда описание изображения

Давайте добавим немного цвета, используя буфер глубины. Мы раскрасим каждый треугольник в зависимости от его глубины. Прелесть объектов PolyCollection в том, что вы можете указать цвет каждого треугольника с помощью массива NumPy, поэтому давайте сделаем это:

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,:]

Теперь все отображается правильно (bunny-8.py):
Вставьте сюда описание изображения

Итоговый сценарий состоит из 57 строк (но его трудно читать):

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()

Исходная ссылка: Matplotlib визуализирует 3D-модели — BimAnt.

рекомендация

отblog.csdn.net/shebao3333/article/details/132826064