Эта статья продолжение основной статьи:
Как сделать 3D версию любого фильма на примере StarWars4 (DepthAnythingV2 + Parallax) (https://habr.com/ru/articles/897860/)
Сначала рекомендуется ознакомиться с первой статьей, там все основные детали: суть алгоритма, необходимые библиотеки, первоначальные скрипты и описание параметров в них. Также там приведены примеры обработанных изображений и есть ссылки на готовые 3D видео (отрывок StarWars4), в том числе для VR. Эта статья продолжение, здесь приводится доработанный скрипт и комментарии к нему. Также ниже будут обозначены другие решения, которые можно использовать для конвертации видео из 2D в 3D.
По традиции будут приложены несколько изображений, в том числе анимированные 3D-гифы, примеры того, что можно получить через DepthAnythingV2 + Parallax.
Новый скрипт
Скрипт:
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
# ПАРАМЕТРЫ ОБЩИЕ
# Исходный файл
video_file = "/home/user/video.mkv"
video_name = os.path.splitext(os.path.basename(video_file))[0]
# Папка для выгрузки фреймов и папка для итоговых 3D фреймов
frames_dir = os.path.join(os.path.dirname(video_file), f"{video_name}_frames")
images3d_dir = os.path.join(os.path.dirname(video_file), f"{video_name}_3d")
os.makedirs(frames_dir, exist_ok=True)
os.makedirs(images3d_dir, exist_ok=True)
frame_counter = Value('i', 0) # Счетчик для именования кадров
threads_count = Value('i', 0) # Счетчик текущих потоков, чтобы не выходить за пределы max_threads
chunk_size = 5000 # Количество файлов на один поток
max_threads = 3 # Максимальное количество потоков
# Устройство для вычислений
device = torch.device('cuda')
# ПАРАМЕТРЫ 3D
PARALLAX_SCALE = 15 # Максимальное значение параллакса в пикселях, рекомендуется от 10 до 20
PARALLAX_METHOD = 2 # 1 или 2
INPAINT_RADIUS = 2 # Рекомендуется от 2 до 5, оптимальное значение 2-3
INTERPOLATION_TYPE = cv2.INTER_LINEAR
TYPE3D = "FSBS" # 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
left = (new_width - current_width) // 2
# Создаем черный холст нужного размера
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(image):
''' Создание карты глубины для изображения '''
# Вычисление глубины
with torch.no_grad():
depth = model_depth.infer_image(image)
# Нормализация глубины
depth_normalized = (depth - depth.min()) / (depth.max() - depth.min())
return depth_normalized
def image3d_processing_method1(image, depth, height, width):
''' Функция создания стереопары на основе исходного изображения и карты глубины.
Метод1: более быстрый, контуры более сглаженные, но может быть менее точным
'''
# Вычисление значения для параллакса
parallax = depth * PARALLAX_SCALE
# Сетка координат
y, x = np.indices((height, width), dtype=np.float32)
# Вычисление смещений
shift_left = np.clip(x - parallax, 0, width - 1)
shift_right = np.clip(x + parallax, 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)
return left_image, right_image
def image3d_processing_method2(image, depth, height, width):
''' Функция создания стереопары на основе исходного изображения и карты глубины.
Метод2: немного медленнее первого метода, но может быть точнее.
'''
# Вычисление значения для параллакса
parallax = depth * PARALLAX_SCALE
# Округление параллакса и преобразование в int32
shift = np.round(parallax).astype(np.int32)
# Сетка координат
y, x = np.indices((height, width))
# Подготовка изображений
left_image = np.zeros_like(image)
right_image = np.zeros_like(image)
# Формирование левого изображения по смещенным координатам
x_src_left = x - shift
valid_left = (x_src_left >= 0) & (x_src_left < width)
left_image[y[valid_left], x[valid_left]] = image[y[valid_left], x_src_left[valid_left]]
# Формирование правого изображения по смещенным координатам
x_src_right = x + shift
valid_right = (x_src_right >= 0) & (x_src_right < width)
right_image[y[valid_right], x[valid_right]] = image[y[valid_right], x_src_right[valid_right]]
# Маски пропущенных пикселей для инпейнтинга
mask_left = (~valid_left).astype(np.uint8) * 255
mask_right = (~valid_right).astype(np.uint8) * 255
# Заполнение пустот через инпейнтинг
left_image = cv2.inpaint(left_image, mask_left, INPAINT_RADIUS, cv2.INPAINT_TELEA)
right_image = cv2.inpaint(right_image, mask_right, INPAINT_RADIUS, cv2.INPAINT_TELEA)
return left_image, right_image
def image3d_combining(left_image, right_image, height, width):
''' Объединение изображений стереопары в единое 3D изображение '''
# Корректировка размеров изображений, если заданы new_width и new_height
if new_width and new_height:
left_image, right_image = image_size_correction(height, width, left_image, right_image)
# Меняем значения исходных размеров изображений на new_height и new_width для корректного склеивания ниже
height = new_height
width = new_width
# Порядок изображений, сначала левое или сначала правое
img1, img2 = (left_image, right_image) if LEFT_RIGHT == "LEFT" else (right_image, left_image)
# Объединение левого и правого изображений в единое 3D изображение
if TYPE3D == "HSBS": # Сужение и склейка изображений по горизонтали
combined_image = np.hstack((cv2.resize(img1, (width // 2, height), interpolation=cv2.INTER_AREA),
cv2.resize(img2, (width // 2, height), interpolation=cv2.INTER_AREA)))
elif TYPE3D == "HOU": # Сужение и склейка изображений по вертикали
combined_image = np.vstack((cv2.resize(img1, (width, height // 2), interpolation=cv2.INTER_AREA),
cv2.resize(img2, (width, height // 2), interpolation=cv2.INTER_AREA)))
elif TYPE3D == "FSBS": # Склейка изображений по горизонтали
combined_image = np.hstack((img1, img2))
elif TYPE3D == "FOU": # Склейка изображений по вертикали
combined_image = np.vstack((img1, img2))
return combined_image
def get_total_frames():
''' Определение точного количества фреймов в видео.
Сначала пробуется первый вариант, он быстрее, но срабатывает редко.
Если не сработал первый вариант, пробуется второй, он долгий, но обычно отрабатывает хорошо.
'''
cmd1 = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
"-of", "default=nokey=1:noprint_wrappers=1", video_file]
cmd2 = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-count_frames",
"-of", "default=nokey=1:noprint_wrappers=1", video_file]
try:
result = subprocess.check_output(cmd1).splitlines()[0].decode().strip()
print(f"Вариант1: {result}")
if result != "N/A":
return int(result)
except Exception:
pass
try:
result = subprocess.check_output(cmd2).splitlines()[0].decode().strip()
print(f"Вариант2: {result}")
if result != "N/A":
return int(result)
except Exception:
pass
raise RuntimeError("Ошибка, не удалось определить исходное количество фреймов.")
def extract_frames(start_frame, end_frame):
''' Извлечение фреймов и распределение их по чанкам исходя из chunk_size '''
frames_to_process = end_frame - start_frame + 1
extracted_frames = []
with frame_counter.get_lock():
start_counter = frame_counter.value
frame_counter.value += frames_to_process
for chunk_start in range(start_frame, end_frame + 1, chunk_size):
chunk_end = min(chunk_start + chunk_size - 1, end_frame)
extract_frames_dir = os.path.join(frames_dir, f"file_%06d.png")
cmd = [
"ffmpeg", "-hwaccel", "cuda", "-i", video_file,
"-vf", f"select='between(n,{chunk_start},{chunk_end})'",
"-vsync", "0", "-start_number", str(chunk_start), extract_frames_dir
]
subprocess.run(cmd, check=True)
print(cmd)
for i in range(chunk_end - chunk_start + 1):
frame_number = start_counter + i + (chunk_start - start_frame)
frame_path = extract_frames_dir % frame_number
extracted_frames.append(frame_path)
return extracted_frames
def chunk_processing(extracted_frames):
''' Старт обработки каждого заполненного чанка '''
for frame_path in extracted_frames:
# Извлекаем имя изображения для последующего сохранения 3D изображения
frame_name = os.path.splitext(os.path.basename(frame_path))[0]
# Загрузка изображения
image = cv2.imread(frame_path)
# Размеры изображения
height, width = image.shape[:2]
# Запуск depth_processing и получение карты глубины
depth = depth_processing(image)
# Запуск image3d_processing и получение двух изображений стереопары
if PARALLAX_METHOD == 1:
left_image, right_image = image3d_processing_method1(image, depth, height, width)
elif PARALLAX_METHOD == 2:
left_image, right_image = image3d_processing_method2(image, depth, height, width)
else:
print(f"Задайте корректный {PARALLAX_METHOD}.")
# Объединение стереопары в общее 3D изображение
image3d = image3d_combining(left_image, right_image, height, width)
# Сохранение 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])
#cv2.imwrite(output_image3d_path, image3d) # Если PNG
# Удаление исходного файла
os.remove(frame_path)
with threads_count.get_lock():
threads_count.value = max(1, threads_count.value - 1) # Уменьшение счетчика после завершения текущего потока
def run_processing():
''' Глобальная функция старта обработки с учетом многопоточности'''
# Получение количества фреймов в видео
total_frames = get_total_frames()
# Управление потоками
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("ГОТОВО.")
# Очищаем Cuda
del model_depth
torch.cuda.empty_cache()
Данный скрипт позволяет обрабатывать видео без предварительной выгрузки фреймов. Точнее выгрузка фреймов есть, но она происходит напрямую из видео-файла отдельными пакетами.
За выгрузку отвечает ffmpeg. Задаем chunk_size (количество фреймов на поток) и max_threads (количество потоков), скрипт последовательно обрабатывает все фреймы до последнего. Предварительно мы получаем значение общего количества фреймов с помощью ffprobe. Все подробности по настройке параметров в предыдущей (основной) статье. Могу лишь заметить, что на моей конфигурации (AMD Ryzen 5 PRO 3600, 32Gb DDR4, RTX 3060 12GB) в среднем достаточно 3-5 потоков и примерно по 5000 фреймов на поток.
Для чего вообще возникла идея с многопоточной обработкой (псевдо-многопоточной)? Во-первых, медленная выгрузка фреймов. Мы выгружаем по диапазону, например:ffmpeg -hwaccel cuda -i video.mkv -vf "select='between(n,5000,10000)'" -vsync 0 -start_number 5000 "extracted_frames/file_%06d.png"
затем:ffmpeg -hwaccel cuda -i "video.mkv" -vf "select='between(n,10001,15000)'" -vsync 0 -start_number 10001 "extracted_frames/file_%06d.png"
и тд.
Соответственно ffmpeg должен пересчитать все кадры перед выгрузкой (а возможно вообще все кадры), чтобы выгрузить корректно. Это также зависит от конкретного кодека и алгоритма кодирования. У меня пока не получилось ускорить этот процесс, чтобы соблюсти точную синхронизацию (чтобы не было пропуска или дублирования кадров), если кто-то знает, как это сделать лучше, поделитесь пожалуйста в комментариях, я протестирую и обновлю скрипт.

Вкратце по команде:ffmpeg -hwaccel cuda -i video.mkv -vf "select='between(n,5000,10000)'" -vsync 0 -start_number 5000 "extracted_frames/file_%06d.png"
"-hwaccel cuda" - используем CUDA для выгрузки, обычно быстрее чем на CPU
"-i video.mkv" - исходный видео-файл
"-vf "select='between(n,5000,10000)'"" - фильтр диапазона, от 5000 до 10000 фрейма
"-vsync 0" - отключаем синхронизацию по временным меткам, выгружаем фреймы как есть
"-start_number" - счетчик для названия, начинаем от 5000
""extracted_frames/file_%06d.png"" - путь куда будут выгружаться фреймы и маска файлов, где %06d - 6-значный счетчик, файлы будут вида "file_005000.png", "file_005001.png" и тд.
После полной обработки из полученных фреймов нужно будет "вручную" скомпилировать фильм, не забыв подключить звуковые дорожки из исходного файла.
Пример команды:ffmpeg -r 24000/1001 -i "frames_3d/file_%06d.jpg" -i video.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 video_3d.mkv
Здесь:
"-r 24000/1001" - частота кадров (как было в исходнике), 24000/1001=23,976 кадров в секунду
"-i "frames_3d/file_%06d.jpg"" - папка с итоговыми фреймами
"-i video.mkv" - исходный видео-файл для экспорта из него аудио-дорожек
"-c:v hevc_nvenc" - кодек, в данном случае для CUDA - HEVC H265(быстро и качественно)
"-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 video.mkv" без перекодирования, "-c:a copy" - прямое копирование
"-pix_fmt yuv420p" - цветовой формат пикселей, для выходных видео рекомендуется использовать yuv420p
"video_3d.mkv" - имя выходного файла
Скрипт можно доработать и включить эту команду на автовыполнение после завершения обработки фреймов, например так:
Код:
compile_video3d = [
"ffmpeg",
"-r", "24000/1001",
"-i", "frames_3d/file_%06d.jpg",
"-i", "video.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",
"video_3d.mkv"
]
subprocess.run(compile_video3d, check=True)
Лично я предпочитаю делать вручную, т.к. постоянно приходится что-то корректировать, например убирать часть аудио-дорожек, или экспериментировать с кодеками, фреймрейтом и чем угодно еще.
После компиляции нужно не забыть удалить каталог с полученными фреймами.

Новая функция параллакса
В скрипте появилась новая функция создания параллакса, теперь их две:
image3d_processing_method1
image3d_processing_method2
Первая более быстрая и с более мягким сглаживанием при смещениях. Вторая делает смещения по другому принципу, 3D получается немного другим. Объекты через эти функции могут смещаться по разному, рекомендую проверить оба варианта и выбрать предпочтительный. Глобально 3D-объем хорошо получается через оба метода, и в целом картинки очень похожи, но в то же время есть заметные различия. Выбор метода задается через параметр PARALLAX_METHOD, доступны 2 варианта: 1 и 2 соответственно.
Появился еще один новый параметр - INPAINT_RADIUS. Это радиус заполнения смещений в пикселях для второго метода (image3d_processing_method2). В данном случае это заполнение соседними пикселями на краях изображений при их смещении. То есть, это заполнение черной рамки вокруг смещенных изображений, чтобы вместо черного цвета использовались соседние пиксели. Рекомендуется от 2 до 5, в большинстве случаев достаточно 2-3. Если значение больше, например INPAINT_RADIUS = 15, тогда края будут слишком размыты и время обработки существенно увеличится. Если же наоборот, выставить 0 или 1, тогда рамка будет выглядеть слишком резкой и не точной. В общем, в большинстве случаев достаточно указать 2 или 3 пикселя.
Все остальные параметры описаны в основной статье, не буду дублировать здесь.

Другие решения
VapourSynth
В комментариях к предыдущим статьям мне подсказали пару интересных решений.
Во-первых, вместо цепочки: выгрузка фреймов -> их обработка -> компиляция из готовых фреймов итогового видео, можно использовать промежуточный сервер обработки видео на лету, например VapourSynth. Схема там примерно в следующем: ffmpeg выгружает фрейм, сразу (без сохранения) передает его в функцию обработки (в данном случае это генерация 3D-версии кадра), полученный фрейм кодируется в выходной видео-файл (точнее наверное встает в очередь на кодирование). Все это происходит в RAM / VRAM, минуя промежуточные этапы сохранения кадров на жесткий диск.
Я пока не экспериментировал с этим. Попробовал поставить на Убунту, но VapourSynth стал требовать самых последних версий ffmpeg и некоторых других библиотек (apt update не помог, стабильных версий оказалось не достаточно). Пришлось компилировать вручную последний ffmpeg (хотя лично меня вполне устраивал последний стабильный), и еще несколько других библиотек, но VapourSynth так и не получилось завести. Позже я обязательно вернусь к этому, когда будет больше свободного времени. Возможно под Windows это дело заводится проще.
Важный момент по обработке на лету. С одной стороны это удобно, с другой - есть нюансы. Обработка одного фильма на модели Depth-Anything-V2 Large может длиться больше суток, а то и несколько. Я приводил приблизительный расчет для фильма StarWars4 формата FullHD продолжительностью 2 часа 4 минуты в основной статье. На моей конфигурации (AMD Ryzen 5 PRO 3600, 32Gb DDR4, RTX 3060 12GB) с моделью Large потребовалось бы порядка 32 часов на обработку данного фильма, и если в процессе произойдет сбой или случайно выключится компьютер - придется начинать все с начала.

Другой момент. Надо быть точно уверенным в исходниках. В статье про апскейл старых видео (https://habr.com/ru/articles/904784/) я подробно описал, какие проблемы могут возникнуть при работе с некоторыми исходниками и форматами, особенно если это DVD-MPEG2 или что-то другое из юрского периода той эпохи. Там могут быть проблемы с точным определением частоты кадров, с выходным форматом изображения и чем угодно еще. Это нужно учитывать и предварительно проверять исходники и то, что из них получается на выходе.
А так, в целом, реализация с сервером обработки без сохранения фреймов - замечательная идея, ведь здесь совсем не требуется место под фреймы на диске, а если мы работаем с форматом 4K и PNG, это очень критичный момент.
Другая библиотека для конвертации 2D -> 3D
К комментариях также подсказали другое возможное решение:
https://github.com/nagadomi/nunif/tree/master/iw3
Я его не пробовал, лишь бегло посмотрел. Там есть GUI и очень много настроек. Можно выбрать модель глубины, метод обработки, есть поддержка анаглифа и много чего еще. Пожалуй в этой реализации будет сложнее разобраться, но решение определенно заслуживает внимания.
Заключение
За сим я наверное буду заканчивать с серией публикаций по теме конвертации видео из 2D в 3D. Еще раз напоминаю основную статью:
Как сделать 3D версию любого фильма на примере StarWars4 (DepthAnythingV2 + Parallax)
Там более подробно - как это работает, описание параметров, примеры изображений и тд.
Скрипты я периодически обновляю, за актуальными версиями можно следить тут:
https://github.com/peterplv/MakeAnythingStereo3D, либо в Тг-канале (https://t.me/peter_touch_ai).
На эксперименты я потратил несколько недель, просмотрел около десятка фильмов в 3D (это была исключительно приятная часть).
Вообще, изначально появилось желание "отдохнуть", отвлечься от основной деятельности (LLM, RAGи, агенты и их применение в различных задачах), переключиться на что-то более "легкое" и приятное. В итоге отдых перешел в практическое русло и получилось реализовать 2 pet-проекта - конвертацию видео 2D -> 3D, и апскейл старых видео (статья об этом). Параллельно пришлось глубже изучить незаменимый ffmpeg, погрузиться в некоторые форматы видео, немного в структуру используемых нейронок, познакомиться с принципом эффекта параллакса и тд тд. В общем, полезный получился отдых :)
Буду признателен, если поставите лайк за старания.
До прибудет с вами сила.
