Как сделать 3D версию любого фильма на примере StarWars4 (DepthAnythingV2 + Parallax). 3d.. 3d. ai.. 3d. ai. depth.. 3d. ai. depth. starwars.. 3d. ai. depth. starwars. нейросети.

Заголовок не совсем корректен, потому, что 3D версию можно сделать любого 2D материала: фильма, мультфильма, своих личных видео/фото и тд, да хоть скриншот с рабочего стола можно сделать в 3D. Но в данном материале мы будем делать 3D версию фильма.

В качестве материала возьмем Звездные войны. Эпизод IV: Новая надежда (Star Wars. Episode IV: A New Hope, 1977).

Для этого нам понадобятся:

  • ПК и видеокарта с поддержкой CUDA

  • Python

  • Библиотека Depth‑Anything‑V2 

  • ffmpeg

  • Достаточное количество дискового пространства. Для обычного фильма FullHD 1080p, длительностью ~1.5–2 часа, потребуется порядка 400–500Гб для исходных фреймов в формате PNG, и 150–200Гб для итоговых 3D‑фреймов в формате JPG с наивысшим качеством. На самом деле можно сократить необходимый объем для исходных данных, фреймы можно выгружать частями, об этом будет ниже.

Моя конфигурация для этой задачи:

  • Gigabyte A520M, AMD Ryzen 5 PRO 3600, 32Gb DDR4 3200 MT/s (16+16)

  • Gigabyte GeForce RTX 3060 12GB, CUDA Version: 12.5

  • Ubuntu 22.04

Кратко суть алгоритма:

  • С помощью ffmpeg распаковываем фильм по кадрам

  • С помощью Depth-Anything-V2 генерируем для каждого кадра карту глубины

  • Для каждой пары изображений “Исходный кадр” + “Карта глубины для этого кадра” генерируем 3D изображение через эффект параллакса

  • С помощью ffmpeg кодируем полученные 3D изображения в 3D версию фильма + присоединяем из исходного материала аудио-дорожки

  • Смотрим и удивляемся, что это работает

Забегая наперед скажу – да, это работает. Качество 3D отличное, ни за что не подумаешь, что 3D синтезировано программно.

А теперь к делу.

Установка ПО

Установка ffmpeg

Для Windows

Качаем один из последних build, распаковываем архив, целиком либо только файл ffmpeg.exe (здесь нам нужен только он), сохраняем его например в c:ffmpeg.
Можно прописать путь к папке ffmpeg в PATH, чтобы была возможность вызывать ffmpeg из командной строки в любом месте системы.

Не буду задерживаться на этом этапе, думаю тут все очевидно.

Установка Depth-Anything-V2

Github проекта

Собственно описание от туда:

git clone https://github.com/DepthAnything/Depth-Anything-V2
cd Depth-Anything-V2
pip install -r requirements.txt

Также в системе должен быть установлен numpy:

pip install numpy

Отдельно нужно скачать модели

Я работаю с Large-моделью (335.3M параметров, размер ~1280Mb). Также хорошо себя показала Base-модель (97.5M параметров, размер ~372Mb). Еще есть Small-модель (24.8M параметров, размер ~95Mb) и еще на сайте указано “Coming soon” для модели Giant на 1.3B параметров, но по состоянию на 03.04.2025 ее нет для скачивания.

Еще о моделях. Я тестировал все 3 модели, все они подходят для этой задачи, даже с моделью Small получаются хорошие объемные 3D. Лично я остановился на Large модели, т. к. скорость обработки для меня не является критичным местом (средний фильм делается в пределах суток), а качество Large модели заметно лучше, особенно в деталях. С Base моделью так же получаются отличные 3D, а средний фильм делается за ночь.

Этап 0: пробный запуск

В качестве пробника возьмем этот кадр:

C-3PO и R2-D2 еще не подозревают, что скоро станут 3D

C-3PO и R2-D2 еще не подозревают, что скоро станут 3D

На странице Depth-Anything-V2 есть пример запуска для создания карты глубины:

Код:
import cv2
import torch

from depth_anything_v2.dpt import DepthAnythingV2

DEVICE = 'cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu'

model_configs = {
    'vits': {'encoder': 'vits', 'features': 64, 'out_channels': [48, 96, 192, 384]},
    'vitb': {'encoder': 'vitb', 'features': 128, 'out_channels': [96, 192, 384, 768]},
    'vitl': {'encoder': 'vitl', 'features': 256, 'out_channels': [256, 512, 1024, 1024]},
    'vitg': {'encoder': 'vitg', 'features': 384, 'out_channels': [1536, 1536, 1536, 1536]}
}

encoder = 'vitl' # or 'vits', 'vitb', 'vitg'

model = DepthAnythingV2(**model_configs[encoder])
model.load_state_dict(torch.load(f'checkpoints/depth_anything_v2_{encoder}.pth', map_location='cpu'))
model = model.to(DEVICE).eval()

raw_img = cv2.imread('your/image/path')
depth = model.infer_image(raw_img) # HxW raw depth map in numpy

Немного доработаем этот скрипт и добавим сохранение результатов в файл:

Код:
import os
import cv2
import torch
import numpy as np

from depth_anything_v2.dpt import DepthAnythingV2


# ПАРАМЕТРЫ ОБЩИЕ
# Путь к исходному файлу
image_path = "/home/user/sw4test/file_000790.png"

# В какую папку сохранить результат
output_dir = "/home/user/sw4test"  # Сохраняем в той же папке, имя файла будет file_000790_depth.png

# Устройство для вычислений
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')


# ПАРАМЕТРЫ МОДЕЛИ
# Путь к папке с моделями, указывать без слеша на конце, например: "/home/user/DepthAnythingV2/models"
depth_model_dir = "/home/user/DepthAnythingV2/models"

model_depth_configs = {
        'vits': {'encoder': 'vits', 'features': 64, 'out_channels': [48, 96, 192, 384]},
        'vitb': {'encoder': 'vitb', 'features': 128, 'out_channels': [96, 192, 384, 768]},
        'vitl': {'encoder': 'vitl', 'features': 256, 'out_channels': [256, 512, 1024, 1024]}
}

encoder = 'vitl' # 'vitl', 'vitb', 'vits'

model_depth = DepthAnythingV2(**model_depth_configs[encoder])
model_depth.load_state_dict(torch.load(f'{depth_model_dir}/depth_anything_v2_{encoder}.pth', weights_only=True, map_location=device))
model_depth = model_depth.to(device).eval()


# НАЧАЛО ОБРАБОТКИ
# Загрузка изображения
raw_img = cv2.imread(image_path)

# Извлекаем имя изображения для последующего сохранения карты глубины
image_name = os.path.splitext(os.path.basename(image_path))[0]

# Вычисление глубины
with torch.no_grad():
    depth = model_depth.infer_image(raw_img)
    
# Нормализация значений глубины от 0 до 255
depth_normalized = cv2.normalize(depth, None, 0, 255, norm_type=cv2.NORM_MINMAX)
depth_normalized = depth_normalized.astype(np.uint8)

# Сохранение карты глубины
output_path = os.path.join(output_dir, f'{image_name}_depth.png')
cv2.imwrite(output_path, depth_normalized)


# ОПЦИОНАЛЬНО: СОХРАНЯЕМ КАРТУ ГЛУБИНЫ В ЦВЕТЕ
depth_colored = cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET)

# Сохранение карты глубины в цвете
output_path = os.path.join(output_dir, f'{image_name}_depth_color.png')
cv2.imwrite(output_path, depth_colored)


print("ГОТОВО.")


# Удаляем модель из памяти и очищаем кеш Cuda
del model_depth
torch.cuda.empty_cache()

Получим карту глубины:

Карта глубины, чем светлее объект, тем он ближе

Карта глубины, чем светлее объект, тем он ближе

Или цветная версия, для наглядности (в работе она нам не понадобится):

Цветная карта глубины, от темно-красного (ближе) до темно синего (дальше)

Цветная карта глубины, от темно-красного (ближе) до темно синего (дальше)

Теперь, на основе полученной карты глубины, сделаем 3D изображение, например в формате FOU (Full Over-Under):

Код:
import os
import cv2
import numpy as np


# ПАРАМЕТРЫ ОБЩИЕ
# Путь к исходному файлу
image_path = "/home/user/sw4test/file_000790.png"

# Путь к карте глубине для исходного файла
depth_path = "/home/user/sw4test/file_000790_depth.png"

# В какую папку сохранить 3D изображение
output_dir = "/home/user/sw4test"  # Сохраняем в той же папке, имя файла будет file_000790_3d.jpg


# ПАРАМЕТРЫ 3D
PARALLAX_SCALE = 15  # Рекомендуется от 10 до 20
INTERPOLATION_TYPE = cv2.INTER_LINEAR
TYPE3D = "FOU"  # HSBS, FSBS, HOU, FOU
LEFT_RIGHT = "LEFT"  # LEFT or RIGHT

# 0 - если не нужно менять размеры полученного изображения
new_width = 1920
new_height = 1080


def image_size_correction(current_height, current_width, left_image, right_image):
    ''' Коррекция размеров изображения, если заданы new_width и new_height '''
    
    # Вычисляем смещения для центрирования
    top = (new_height - current_height) // 2
    bottom = new_height - current_height - top
    left = (new_width - current_width) // 2
    right = new_width - current_width - left
    
    # Создаем черное полотно нужного размера
    new_left_image = np.zeros((new_height, new_width, 3), dtype=np.uint8)
    new_right_image = np.zeros((new_height, new_width, 3), dtype=np.uint8)
    
    # Размещаем изображение на черном фоне
    new_left_image[top:top + current_height, left:left + current_width] = left_image
    new_right_image[top:top + current_height, left:left + current_width] = right_image
    
    return new_left_image, new_right_image
    
def image3d_processing(image_name, image, depth):
    ''' Функция создания 3D на основе исходного изображения и его карты глубины '''
    
    # Если размеры исходного кадра и карты глубины не совпадают, глубина масштабируется до размеров изображения
    if image.shape[:2] != depth.shape[:2]:
        depth = cv2.resize(depth, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR)
    
    # Нормализация глубины
    depth = depth.astype(np.float32) / 255.0

    # Создание параллакса
    height, width, _ = image.shape
    parallax = (depth * PARALLAX_SCALE)

    # Координаты пикселей
    x, y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))

    # Вычисление смещений
    shift_left = np.clip(x + parallax.astype(np.float32), 0, width - 1)
    shift_right = np.clip(x - parallax.astype(np.float32), 0, width - 1)

    # Применение смещений с cv2.remap
    left_image = cv2.remap(image, shift_left, y, interpolation=INTERPOLATION_TYPE)
    right_image = cv2.remap(image, shift_right, y, interpolation=INTERPOLATION_TYPE)
    
    if new_width != 0 and new_height != 0:
        left_image, right_image = image_size_correction(height, width, left_image, right_image)
        # Меняем значения исходных размеров изображений на new_height и new_width для корректного склеивания ниже
        height = new_height
        width = new_width
    
    # Объединяем левое и правое изображения в общее 3D
    if TYPE3D == "HSBS":
        # Сужение ширины изображений, чтобы сделать из них одно с общей шириной
        left_image_resized = cv2.resize(left_image, (width // 2, height), interpolation=cv2.INTER_AREA)
        right_image_resized = cv2.resize(right_image, (width // 2, height), interpolation=cv2.INTER_AREA)
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.hstack((left_image_resized, right_image_resized))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.hstack((right_image_resized, left_image_resized))
    elif TYPE3D == "HOU":
        # Сужение высоты изображений, чтобы сделать из них одно с общей высотой
        left_image_resized = cv2.resize(left_image, (width, height // 2), interpolation=cv2.INTER_AREA)
        right_image_resized = cv2.resize(right_image, (width, height // 2), interpolation=cv2.INTER_AREA)
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.vstack((left_image_resized, right_image_resized))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.vstack((right_image_resized, left_image_resized))
    elif TYPE3D == "FSBS":
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.hstack((left_image, right_image))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.hstack((right_image, left_image))
    elif TYPE3D == "FOU":
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.vstack((left_image, right_image))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.vstack((right_image, left_image))

    # Сохранение 3D изображения
    output_path = os.path.join(output_dir, f'{image_name}_3d.jpg')
    cv2.imwrite(output_path, image3d, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
    

# НАЧАЛО ОБРАБОТКИ
# Извлекаем имя изображения для последующего сохранения 3D изображения
image_name = os.path.splitext(os.path.basename(image_path))[0]

# Загрузка изображения и его карты глубины
image = cv2.imread(image_path)  # Исходное изображение
depth = cv2.imread(depth_path, cv2.IMREAD_GRAYSCALE)  # Карта глубины


# ЗАПУСК ОБРАБОТЧИКА
image3d_processing(image_name, image, depth)

print("ГОТОВО.")

Здесь надо пояснить по основным параметрам.

Параметр: PARALLAX_SCALE = 15
Значение параллакса в пикселях, на сколько максимум пикселей будут смещаться дальние объекты (если быть точнее – пиксели), относительно ближних (самый ближний 0, самый дальний 15). Чем больше значение, тем больше глубина. При слишком больших значениях изображение будет несмотрибельное. Важно заметить – смещение происходит для каждого кадра отдельно – для левого и для правого, таким образом суммарный параллакс удваивается.

Рекомендуемое значение от 10 до 20. Я обычно ставлю 15, так получается хорошая глубина без существенных искажений.

Параметр: INTERPOLATION_TYPE = cv2.INTER_LINEAR

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

INTER_NEAREST – ближайший сосед, быстрая и простая интерполяция, не самая качественная
INTER_AREA – лучше подходит при уменьшении изображений, в данном случае не будем рассматривать
INTER_LINEAR – билинейная интерполяция, баланс качества и скорости, самый оптимальный вариант
INTER_CUBIC – бикубическая интерполяция, считается качественнее билинейной, но занимает немного больше времени
INTER_LANCZOS4 – интерполяция Ланцоша в окрестности 8×8 пикселей, самое высокое качество, но работает существенно медленнее остальных

Я тестировал все варианты, внимательно отсматривая результаты, особенной разницы при просмотре 3D не заметил. Поэтому обычно использую быстрый и оптимальный метод – INTER_LINEAR. Но если вопрос скорости для вас не критичный, лучше использовать INTER_LANCZOS4 – самое лучшее качество.

Здесь надо заметить, что разница в скорости в миллисекундах. Например вот замер интерполяции одного кадра 1920×1080:

NEAREST: 0.039 секунд
INTER_AREA: 0.041 секунд
INTER_LINEAR: 0.041 секунд
INTER_CUBIC: 0.053 секунд
INTER_LANCZOS4: 0.090 - 0.096 секунд

Например, между INTER_LINEAR и INTER_LANCZOS4 разница ~50 миллисекунд на обработку одного кадра формата 1920×1080. Это может показаться незначительным, но если умножить 50 мс на 194000 кадров, получится ~162 минуты. То есть, INTER_LINEAR обработает быстрее чем INTER_LANCZOS4 на 162 минуты, или 2 часа 42 минуты. И это только интерполяция параллакса.

Возможно позже я напишу сравнительный обзор всех этих методов, с наглядной демонстрацией и указанием занимаемого времени на обработку, сейчас же могу порекомендовать использовать любой из этих 3х методов: INTER_LINEAR, INTER_CUBIC, INTER_LANCZOS4.

Параметр: TYPE3D = “FOU”
Тип стереопары которую хотим получить:
HSBS (Half Side-by-Side) – половинчатая горизонтальная стереопара
FSBS (Full Side-by-Side) – полная горизонтальная стереопара
HOU (Half Over-Under) – половинчатая вертикальная стереопара
FOU (Full Over-Under) – полная вертикальная стереопара

Думаю здесь все очевидно для тех кто смотрит 3D на своем оборудовании, но на всякий случай поясню.
HSBS – половинчатая горизонтальная стереопара, второй кадр располагается справа первого кадра. Если исходный кадр был разрешением 1920×1080, то оба кадра стереопары сжимаются по горизонтали в 2 раза (в данном случае до 960 пикселей, полное разрешение каждого кадра становится 960×1080), чтобы суммарная ширина всей стереопары сохранилась в исходном формате 1920×1080. При просмотре обе половинки стереопары растягиваются до полного размера на каждый кадр, было 960×1080 – стало 1920×1080. В данном случае происходит существенная потеря детализации, т.к. количество пикселей по горизонтали уменьшается вдвое. Зато размер кадра/видео будет существенно меньше полной стереопары, в 1.5-2 раза.

FSBS – полная горизонтальная стереопара, второй кадр располагается справа первого кадра. Если исходный кадр был разрешением 1920×1080, то оба кадра стереопары создадут общий кадр размером 3840×1080 пикселей. В этом случае не будет потери детализации, но размер кадра/видео станет в 1.5-2 раза больше.

HOU – половинчатая вертикальная стереопара, второй кадр располагается снизу первого кадра. Если исходный кадр был разрешением 1920×1080, то оба кадра стереопары сжимаются по вертикали в 2 раза (в данном случае до 540 пикселей, полное разрешение каждого кадра становится 1920×540).

Есть мнение, что при выборе из половинчатых стереопар лучший выбор это HOU — половинчатая вертикальная стереопара. Это вполне логично, учитывая что количество пикселей здесь теряется меньше, 1080/2=540, вместо 1920/2=960 в случае с горизонтальной стереопарой.

FOU – полная вертикальная стереопара, второй кадр располагается снизу первого кадра. Если исходный кадр был разрешением 1920×1080, то оба кадра стереопары создадут общий кадр размером 1920×2160 пикселей.

Часто на 3D телевизорах работают половинчатые стереопары (HSBS и HOU). На VR-шлемах работают все варианты, и можно получить максимальное удовольствие от просмотра 3D на полных стереопарах (FSBS и FOU).

Параметр: LEFT_RIGHT = “LEFT”
Порядок пары кадров в общем 3D изображении, LEFT – сначала левый, RIGHT – сначала правый. Значение по умолчанию LEFT. Также этот порядок может настраиваться на оборудовании при просмотре 3D видео.

Параметры: new_width = 1920 и new_height = 1080
Важная настройка. Суть в том, что бывают фильмы с “нестандартным” разрешением, например 1920×816 пикселей (как в нашем случае). Если мы будем делать стереопары с таким разрешением, то скорее всего будут проблемы при воспроизведении на оборудовании, которое отображает картинку в стандартном разрешении (например FullHD 1920×1080 16:9), особенно это критично для половинчатых стереопар.

Было найдено простое и рабочее решение – увеличиваем картинку до требуемого разрешения, где недостающие пиксели заполняются черным цветом, проще говоря – добавляем черные поля. Например, разрешение исходного кадра 1920×816, хотим увеличить его до стандартных 1920×1080, указываем в параметрах:

new_width = 1920
new_height = 1080

Таким образом кадр не деформируется (не расширяется и не сужается), а недостающее место заполняется черным цветом. Вместо кадра 1920×816 мы получаем стандартный размер кадра 1920×1080 с добавленным черными полями по вертикали.

Если не требуется менять размер кадра, тогда указываем:

new_width = 0
new_height = 0

Итак, запускаем скрипт, и получаем стереопару:

C-3PO и R2-D2 теперь с двух разных ракурсов, сами того не понимая

C-3PO и R2-D2 теперь с двух разных ракурсов, сами того не понимая

Сделаем из этой пары 3D-Gif, для наглядной демонстрации трехмерности сцены:

C-3PO и R2-D2 объемные!

C-3PO и R2-D2 объемные!
Еще несколько гифок:
Корабль хорошо отрехмерился, как-будто DepthAnythingV2 тренировали и на нем

Корабль хорошо отрехмерился, как-будто DepthAnythingV2 тренировали и на нем
Повстанцы не могут поверить, что они теперь в 3D

Повстанцы не могут поверить, что они теперь в 3D
Хан, Люк и Чуви в восторге

Хан, Люк и Чуви в восторге

Другие фотографии можно посмотреть на моем гугл-диске. Там исходные фреймы, карты глубины, в том числе цветные, и 3D-гифки для наглядности.

Теперь можно приступать к обработке основного материала.

Этап 1: раскадровка исходного видео

Для полной раскадровки видео в формате PNG потребуется достаточно дискового пространства. Например, в нашем случае, фильм формата FullHD, длинной ~2 часа, с частотой кадров 23.976 (24000/1001), имеет ~194000 фреймов, суммарный объемом которых примерно ~430Гб в формате PNG.

Забегая наперед замечу – есть варианты сократить требуемый объем дискового пространства. Можно выгружать не все кадры сразу, а по диапазону, например сначала выгружать фреймы от 0 до 10000, затем от 10001 до 20000 и так далее. Сейчас я работаю над комбайном, который именно это и делает, и вообще вся обработка будет выполняться одним скриптом, но об этом будет в другой статье.

Также можно выгружать исходные фреймы в формате JPG. Этот вариант я не рекомендую, проверял, итоговая картинка заметно хуже, даже если выгружать JPG в самом высоком качестве. Зато после обработки (перед итоговым кодированием в 3D-видео), вполне допустимо сохранять выходные файлы в формат JPG, иначе потребуется слишком много дискового пространства. Например, в случае полных 3D-пар, нам бы потребовалось 430×2= ~860Гб для выходных 3D-фреймов в формате PNG.

Итак, выгружаем фреймы командой:

ffmpeg -i sw4.mkv "/home/user/sw4frames/file_%06d.png"

Здесь:
“-i sw4.mkv” – исходный файл
“/home/user/sw4frames/” – путь куда будут выгружаться фреймы; папка должна быть предварительно создана
“file_%06d.png” – маска файлов, где %06d – 6-значный счетчик начиная с 000000, файлы будут вида “file_000000.png”, “file_000001.png” и тд.

Вариант той же команды, но с использованием CUDA (в зависимости от системы скорее всего будет быстрее):

ffmpeg -hwaccel cuda -i sw4.mkv "/home/user/sw4frames/file_%06d.png"

Этап 2: создание 3D фреймов

Теперь можно приступить к созданию 3D версий фреймов. Ниже скрипт, который делает следующее:

  • последовательно загружает каждый фрейм из исходной папки

  • для каждого фрейма создает карту глубины

  • передает карту глубины + исходный фрейм в функцию создания 3D изображения через параллакс, создает 3D версию фрейма

  • удаляет исходный файл и сохраняет 3D версию в файл JPG

Код:
import os
import subprocess
from threading import Lock
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import Value
import cv2
import torch
import numpy as np

from depth_anything_v2.dpt import DepthAnythingV2


# ПАРАМЕТРЫ ОБЩИЕ
# Папка с исходными кадрами
frames_dir = "/home/user/sw4frames"

# Получаем имя папки с исходными фреймами, чтобы создать папку для 3D фреймов
frames_dir_name = os.path.basename(os.path.normpath(frames_dir))
images3d_dir = os.path.join(os.path.dirname(frames_dir), f"{frames_dir_name}_3d")
os.makedirs(images3d_dir, exist_ok=True)

# Получаем список всех файлов в директории
all_frames_in_directory = [file_name for file_name in os.listdir(frames_dir) if os.path.isfile(os.path.join(frames_dir, file_name))]

frame_counter = Value('i', 0) # Счетчик для именования кадров
threads_count = Value('i', 0) # Счетчик текущих потоков, чтобы не выходить за пределы max_threads

chunk_size = 1000  # Количество файлов на один поток
max_threads = 3 # Максимальное количество потоков

# Устройство для вычислений
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')


# ПАРАМЕТРЫ 3D
PARALLAX_SCALE = 15  # Максимальное значение параллакса в пикселях, рекомендуется от 10 до 20
INTERPOLATION_TYPE = cv2.INTER_LINEAR
TYPE3D = "FOU"  # HSBS, FSBS, HOU, FOU
LEFT_RIGHT = "LEFT"  # LEFT or RIGHT

# 0 - если не нужно менять размеры полученного изображения
new_width = 1920
new_height = 1080

# Путь к папке с моделями, указывать без слеша на конце, например: "/home/user/DepthAnythingV2/models"
depth_model_dir = "/home/user/DepthAnythingV2/models"

model_depth_configs = {
        'vits': {'encoder': 'vits', 'features': 64, 'out_channels': [48, 96, 192, 384]},
        'vitb': {'encoder': 'vitb', 'features': 128, 'out_channels': [96, 192, 384, 768]},
        'vitl': {'encoder': 'vitl', 'features': 256, 'out_channels': [256, 512, 1024, 1024]}
}

encoder = 'vitl' # 'vitl', 'vitb', 'vits'

model_depth = DepthAnythingV2(**model_depth_configs[encoder])
model_depth.load_state_dict(torch.load(f'{depth_model_dir}/depth_anything_v2_{encoder}.pth', weights_only=True, map_location=device))
model_depth = model_depth.to(device).eval()


def image_size_correction(current_height, current_width, left_image, right_image):
    ''' Коррекция размеров изображения если заданы new_width и new_height '''
    
    # Вычисляем смещения для центрирования
    top = (new_height - current_height) // 2
    bottom = new_height - current_height - top
    left = (new_width - current_width) // 2
    right = new_width - current_width - left
    
    # Создаем черное полотно нужного размера
    new_left_image = np.zeros((new_height, new_width, 3), dtype=np.uint8)
    new_right_image = np.zeros((new_height, new_width, 3), dtype=np.uint8)
    
    # Размещаем изображение на черном фоне
    new_left_image[top:top + current_height, left:left + current_width] = left_image
    new_right_image[top:top + current_height, left:left + current_width] = right_image
    
    return new_left_image, new_right_image
            
def depth_processing(frame_name, frame_path):
    ''' Функция создания карты глубины для изображения '''
    
    # Загрузка изображения
    raw_img = cv2.imread(frame_path)

    # Вычисление глубины
    with torch.no_grad():
        depth = model_depth.infer_image(raw_img)
        
    # Нормализация значений глубины от 0 до 255
    depth_normalized = cv2.normalize(depth, None, 0, 255, norm_type=cv2.NORM_MINMAX)
    depth_normalized = depth_normalized.astype(np.uint8)

    return depth_normalized

def image3d_processing(frame_name, frame_path, depth):
    ''' Функция создания 3D на основе исходного изображения и его карты глубины '''
    
    # Загрузка изображения
    image = cv2.imread(frame_path)
    
    # Если размеры исходного кадра и карты глубины не совпадают, глубина масштабируется до размеров изображения
    if image.shape[:2] != depth.shape[:2]:
        depth = cv2.resize(depth, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR)

    # Нормализация глубины
    depth = depth.astype(np.float32) / 255.0

    # Создание параллакса
    height, width, _ = image.shape
    parallax = (depth * PARALLAX_SCALE)

    # Координаты пикселей
    x, y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))

    # Вычисление смещений
    shift_left = np.clip(x + parallax.astype(np.float32), 0, width - 1)
    shift_right = np.clip(x - parallax.astype(np.float32), 0, width - 1)

    # Применение смещений с cv2.remap
    left_image = cv2.remap(image, shift_left, y, interpolation=INTERPOLATION_TYPE)
    right_image = cv2.remap(image, shift_right, y, interpolation=INTERPOLATION_TYPE)
    
    # Меняем размер кадра если заданы new_width и new_height
    if new_width != 0 and new_height != 0:
        left_image, right_image = image_size_correction(height, width, left_image, right_image)
        
        # Меняем значения исходных размеров изображений на new_height и new_width для корректного склеивания ниже
        height = new_height
        width = new_width
    
    # Объединяем левое и правое изображения в общее 3D
    if TYPE3D == "HSBS":
        # Сужение ширины изображений, чтобы сделать из них одно с общей шириной
        left_image_resized = cv2.resize(left_image, (width // 2, height), interpolation=cv2.INTER_AREA)
        right_image_resized = cv2.resize(right_image, (width // 2, height), interpolation=cv2.INTER_AREA)
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.hstack((left_image_resized, right_image_resized))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.hstack((right_image_resized, left_image_resized))
    elif TYPE3D == "HOU":
        # Сужение высоты изображений, чтобы сделать из них одно с общей высотой
        left_image_resized = cv2.resize(left_image, (width, height // 2), interpolation=cv2.INTER_AREA)
        right_image_resized = cv2.resize(right_image, (width, height // 2), interpolation=cv2.INTER_AREA)
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.vstack((left_image_resized, right_image_resized))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.vstack((right_image_resized, left_image_resized))
    elif TYPE3D == "FSBS":
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.hstack((left_image, right_image))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.hstack((right_image, left_image))
    elif TYPE3D == "FOU":
        # Склейка изображений в одно
        if LEFT_RIGHT == "LEFT":
            image3d = np.vstack((left_image, right_image))
        elif LEFT_RIGHT == "RIGHT":
            image3d = np.vstack((right_image, left_image))

    # Сохранение 3D изображения
    output_image3d_path = os.path.join(images3d_dir, f'{frame_name}.jpg')
    cv2.imwrite(output_image3d_path, image3d, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
    
def extract_frames(start_frame, end_frame):
    ''' Функция разбивки файлов изображений по чанкам исходя из chunk_size '''
    
    frames_to_process = end_frame - start_frame + 1
    
    with frame_counter.get_lock():
        start_counter = frame_counter.value
        frame_counter.value += frames_to_process
        
    extracted_frames = []  # Список для хранения путей к файлам
    
    # Получаем словарь со списком файлов в размере чанка
    chunk_files = all_frames_in_directory[start_frame:end_frame+1]  # end_frame включительно
    extracted_frames = [os.path.join(frames_dir, file_name) for file_name in chunk_files]
    
    return extracted_frames

def chunk_processing(extracted_frames):
    ''' Старт обработки каждого заполненного чанка '''
    
    # Обработка depth_processing и image3d_processing
    for frame_path in extracted_frames:
        # Проверяем, что это файл, а не папка
        if not os.path.isfile(frame_path):
            continue
        
        # Извлекаем имя изображения для последующего сохранения 3D изображения
        frame_name = os.path.splitext(os.path.basename(frame_path))[0]

        # Обработка depth_processing
        depth = depth_processing(frame_name, frame_path)

        # Обработка image3d_processing и сохранение результата
        image3d_processing(frame_name, frame_path, depth)

        # Удаление исходного файла
        os.remove(frame_path)
        
    with threads_count.get_lock():
        threads_count.value = max(1, threads_count.value - 1) # Уменьшение счетчика после завершения текущего потока
    
def run_processing():
    ''' Глобальная функция старта обработки с учетом многопоточности'''
    
    # Получение количества файлов в папке с исходниками
    total_frames = len([f for f in os.listdir(frames_dir) if os.path.isfile(os.path.join(frames_dir, f))])
                        
    # Управление потоками
    if total_frames:
        with ThreadPoolExecutor(max_workers=max_threads) as executor:
            futures = []
            for start_frame in range(0, total_frames, chunk_size):
                while True:
                    with threads_count.get_lock():
                        if threads_count.value < max_threads:
                            threads_count.value += 1
                            break
                            
                    time.sleep(5) # Пауза перед повторной проверкой на количество работающих потоков

                end_frame = min(start_frame + chunk_size - 1, total_frames - 1)
                extracted_frames = extract_frames(start_frame, end_frame)
                future = executor.submit(chunk_processing, extracted_frames)
                futures.append(future)
            
            # Ожидаем завершения задач
            for future in futures:
                future.result()


# ЗАПУСК ОБРАБОТЧИКА
run_processing()

print("ГОТОВО.")


# Выгружаем модель
del model_depth
torch.cuda.empty_cache()

Ремарка:
Функция extract_frames в данном скрипте не имеет отношение к “распаковке/извлечению”, как можно подумать из ее названия, ведь кадры уже распакованы и находятся в папке sw4frames. В данном случае она лишь подготавливает пакеты фреймов для каждого потока в количестве chunk_size. Название сохранено для совместимости с будущим скриптом, где распаковка кадров происходит пакетно сразу из исходного видео-файла без необходимости предварительной выгрузки.

В скрипте реализована псевдомногопоточность. Псевдо, потому что это сугубо вытесняющие друг друга потоки, которые выполняют одно и то же. Сделано это с той идеей, что в один момент времени каждый поток может выполнять разные задачи: загружать файл в память, сохранять файл на диск, вычислять карту глубины (GPU), делать параллакс (CPU) и тд. И даже в такой псевдо многопоточности обработка происходит существенно быстрее. Лично для себя я определил оптимальным 2-3 потока, большее количество потоков никак не сказывается на скорости обработки, зато увеличивается объем потребляемой видео-памяти.

Ремарка:
Это касается работы с уже выгруженными фреймами. Я сейчас работаю над скриптом, где фреймы будут выгружаться не все сразу, а пакетами, там псевдомногопоточность работает еще лучше, и эмпирически я определил для себя оптимальными 3-5 потоков. Но об этом будет другая статья.

Ниже сравнение работы скрипта на тестовой выборке в 100 фреймов при разном количестве потоков, модель везде использовалась Large, все прочие настройки идентичны:
1 поток (100 файлов на поток):
Первый прогон: 1 минута 20 секунд.
Контрольный прогон: 1 минута 19 секунд.
Максимальное использование видео-памяти (здесь и далее – без учета зарезервированной): 2675.17 MB.

2 потока (50 файлов на поток) –
Первый прогон: 1 минута 9 секунд.
Контрольный прогон: 1 минута 9 секунд.
Максимальное использование видео-памяти: 3994.97 MB.

10 секунд сэкономили на псевдомногопоточности, или прирост скорости 12.5%. Это может показаться незначительным, но здесь мы обработали только 100 фреймов, тогда как всего у фильма ~194000 фреймов, сэкономить по времени обработки получится несколько часов (~). Примерный подсчет необходимого времени на обработку всех фреймов будет ниже.

3 потока (34+34+32 файлов на поток) –
Первый прогон: 1 минута 9 секунд.
Контрольный прогон: 1 минута 9 секунд.
Максимальное использование видео-памяти: 5351.92 MB.

Конкретно на этой тестовой выборке нет разницы между 2мя и 3мя потоками (кроме повышенного использования видео-памяти на 3х потоках), но на замерах по более крупным выборкам небольшой прирост был.

4 потока (25 файлов на поток) –
Первый прогон: 1 минута 9 секунд.
Контрольный прогон: 1 минута 9 секунд.
Максимальное использование видео-памяти: 6708.48 MB.

Скорость выполнения дальше не меняется, зато меняется объем использования видео-памяти.

Для сравнения, посмотрим сколько времени займет обработка той же тестовой выборки в 2 потока для модели Base:
Первый прогон: 24.30 секунды.
Контрольный прогон: 24.47 секунды.
Максимальное использование видео-памяти: 2415.44 MB.

И для модели Small:
Первый прогон: 11.68 секунд.
Контрольный прогон: 11.59 секунд.
Максимальное использование видео-памяти: 1134.84 MB.

Теперь можно примерно (очень грубо!) оценить, сколько времени потребуется на обработку всех 194000 фреймов.
Для Large модели: Если на 2х потоках для обработки 100 фреймов понадобилось 59 секунд, для 194000 фреймов понадобится 114460 секунд, или ~32 часа.
Для Base модели: ~13 часов.
Для Small модели: ~6 часов.

Этап 3: компиляция 3D-видео

Теперь нужно скомпилировать итоговое 3D-видео с подключением исходных аудио-дорожек.
Я использую кодек hevc_nvenc, кодирование происходит на GPU, что существенно быстрее CPU.

Команда:

ffmpeg -r 24000/1001 -i "/home/user/sw4frames_3d/file_%06d.jpg" -i sw4.mkv -c:v hevc_nvenc -b:v 20M -minrate 10M -maxrate 30M -bufsize 60M -preset p7 -map 0:v -map 1:a -c:a copy -pix_fmt yuv420p sw4_3d.mp4

Здесь:
“-r 24000/1001” – частота кадров исходного видео, 24000/1001=23,976 кадров в секунду
“-i “/home/user/sw4frames_3d/file_%06d.jpg”” – папка с 3D фреймами
“-i sw4.mkv” – исходный файл для экспорта аудио-дорожек
“-c:v hevc_nvenc” – кодек
“-b:v 20M -minrate 10M -maxrate 30M” – переменный битрейт, среднее значение 20Мбит/сек, минимальное 10Мбит/сек, максимальное 30Мбит/сек
“-bufsize 60M” – размер буфера для переменного битрейта, рекомендуется использовать 2x от maxrate (2x30M=60M), либо можно вовсе не указывать, оставить на усмотрение ffmpeg
“-preset p7” – пресет 7 для кодека hevc_nvenc, высокое качество
“-map 0:v” – указываем использовать папку с фреймами, которую указали ранее, для видео
“-map 1:a -c:a copy” – указываем использовать аудио-дорожки из “-i sw4.mkv” без пережатия, “-c:a copy” – прямое копирование
“-pix_fmt yuv420p” – цветовой формат пикселей, для выходных видео рекомендуется использовать yuv420p
“sw4_3d.mp4” – имя выходного файла

Ждем компиляции и… наслаждаемся просмотром.

Другие моменты

Мы обработали фильм формата FullHD (1920×1080). Также можно работать с другими форматами, в том числе 4K UltraHD (3840×2160). Я пока не делал полноценные 3D версии в 4K, но тестировал работу с этим разрешением, существенных различий по времени обработки я не заметил. Но тут нужно понимать, что для фреймов в разрешении 3840×2160 требуется значительно больше дискового пространства, особенно это касается формата PNG, здесь требуемый размер увеличивается в 4 раза в сравнении с фреймами 1920×1080.

Кстати о занимаемом месте на диске. Так как конвертация в 3D происходит относительно быстро, не обязательно хранить на диске обе версии фильма, обычную и 3D, в любой момент можно синтезировать 3D версию, посмотреть и удалить, а в качестве архивной копии хранить исходную версию. Причем исходная версия может быть в 4K, а синтезировать 3D версию, для скорости, можно в разрешении FullHD. Команда для выгрузки фреймов будет:

ffmpeg -i video4k.mkv -vf "scale=1920:1080" "/home/user/frames_in/file_%06d.png"

Или другая команда, где указываем только ширину кадра (высота рассчитается автоматически):

ffmpeg -i video4k.mkv -vf "scale=1920:-2" "/home/user/frames_in/file_%06d.png"

Если исходное видео с большой частотой кадров, например 60 кадров в секунду (многие видео на YouTube), тогда разумнее сократить частоту кадров до стандартных 23.976 (24000/1001), это, на минуточку, ускорит обработку в 2.5 раза. Команда для выгрузки фреймов будет:

ffmpeg -i video4k.mkv -vf "fps=24000/1001" "/home/user/frames_in/file_%06d.png"

Или объединенная команда:

ffmpeg -i video4k.mkv -vf "scale=1920:-2,fps=24000/1001" "/home/user/frames_in/file_%06d.png"

Еще важный момент — Depth‑Anything‑V2 также хорошо работает с черно‑белыми изображениями, вообще никакой разницы в обработке с цветными. Я уже пробовал «объемить» черно‑белые фильмы, результаты отличные. Лично мне даже больше зашло именно черно‑белое 3D‑кино, там объем ощущается по другому, но это наверное личные предпочтения.

Есть ли недостатки метода? Да, но незначительные. Если не знать, что смотришь ненастоящее 3D, а синтезированное, то скорее всего ничего не заметишь. В некоторых динамичных сценах, где быстрая смена объектов, объем соседних кадров может меняться. Например, в текущем кадре стоит человек и рядом проносится мотоцикл, а в следующем кадре (именно кадре, не секунде) мотоцикла уже нет, только человек, тогда объемность этих двух кадров скорее всего будет разная, слишком разная компоновка кадров. Но, еще раз, это можно заметить только если специально стараться.

Зато есть забавные побочные явления. Например, отражение в зеркале скорее всего будет трехмерным, или рисунок на бумаге. Но это не вызывает дискомфорт, скорее наоборот, ощущается какая-то дополнительная магия, своего рода интерактив, что-ли, наверное так можно сказать. Я делал трехмерную версию одного музыкального фестиваля, который когда-то посещал, там был большой стенд – план-схема мероприятия, где были нарисованы различные объекты с видом сверху, и вся эта схема на видео стала объемная, смотрелось очень интересно и как ни странно органично.

Заключение

Получается, по такой схеме можно конвертировать любой фильм в 3D? Да, абсолютно любой фильм, и вообще любой материал, например видео с YouTube. Кстати, на YouTube много видео от первого лица, например у тревел-блогеров, они очень хорошо смотрятся в объеме.

У меня огромный список фильмов, которые я бы хотел пересмотреть в 3D. Взять например фильмы Кристофера Нолана, который был против 3D (что в общем понятно, 3D оборудование существенно усложняет и ограничивает процесс съемки). Теперь все его фильмы можно пересмотреть в хорошем 3D, а учитывая его любовь к крупному плану, игрой со светом и тенями, объемные версии должны смотреться великолепно. Фильмы Кубрика, с его перфекционизмом в компоновке сцен, идеальной геометрией и тд, тут без комментариев. Другие старые фильмы, например трилогии “Назад в будущее”, “Индиана Джонс” и многие другие картины ждут своей очереди, все хочется пересмотреть.
Я уже посмотрел трилогию первых Звездных войн (эпизоды 4, 5, 6) в 3D, про восторг уже не буду писать, думаю это и так понятно, замечу лишь, что эти картины в 3D смотрятся свежее, как-будто объем их осовременивает. Все, пора заканчивать с лирикой.

В планах написать еще несколько статей по теме. Одна из них – сравнение разных моделей Depth-Anything-V2 (Large, Base, Small). Другая статья планируется вокруг “комбайна” (сейчас я работаю над ним), где конвертация будет происходить полностью или почти полностью в автоматическом режиме, сразу из исходного видео-файла. Не нужно будет вручную, сначала выгружать фреймы, рендерить их, затем компилировать из них итоговое видео. Самое главное – выгрузка фреймов будет происходить партиями, соответственно понадобится существенно меньше дискового пространства для обработки всего материала. Ну а конечная цель – сделать на базе этих скриптов готовую утилиту с простым GUI. Но это оказалось не так просто, как думалось изначально. Постоянно вылезают штуки, которые нужно учитывать. Например, нестандартное разрешение исходного видео, дубли или пропуски кадров, рассинхронизация со звуком и тд. Можете подписаться на меня, чтобы следить за новостями.

Еще планирую написать как я апскейлю фильмы из DVD в FullHD и 4K современными моделями, код и модели также будут.

Это моя первая статья здесь, с готовностью выслушаю критику или дополнения к материалу.

Также можете подписаться на мой Тг-канал: Петр исследует ИИ (@peter_touch_ai). Туда я буду постить анонсы новых статей, а также размещать какие-либо допматериалы, которые по тем или иным причинам не войдут в основные статьи (например чтобы не перегружать их). Вообще планирую писать вокруг ИИ, используемых нейросетях, тестах и применении различных моделей, ComputerVision, NLP, LLM, RAG, агентах, автоматизации и тд.

Скрипты из статьи доступны на GitHub, там я их буду периодически обновлять.

Еще раз ссылка на гугл-диск с примерами и 3D-гифами.

Автор: peterplv

Источник

Рейтинг@Mail.ru
Rambler's Top100