Эта статья продолжение основной статьи:
Как сделать 3D версию любого фильма на примере StarWars4 (DepthAnythingV2 + Parallax)
Сначала рекомендуется ознакомиться с первой статьей, там все основные детали: суть алгоритма, необходимые библиотеки, первоначальные скрипты и описание параметров в них. Также там приведены примеры обработанных изображений и есть ссылки на готовые 3D видео (отрывок StarWars4), в том числе для VR. Эта статья продолжение, здесь приводится доработанный скрипт и комментарии к нему. Также ниже будут обозначены другие решения, которые можно использовать для конвертации видео из 2D в 3D.
По традиции будут приложены несколько изображений, в том числе анимированные 3D-гифы, примеры того, что можно получить через DepthAnythingV2 + Parallax.
Новый скрипт
Скрипт:
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import Value
import cv2
import torch
import numpy as np
from depth_anything_v2.dpt import DepthAnythingV2
# ПАРАМЕТРЫ ОБЩИЕ
# Путь к папке с моделями генерации глубины
depth_models_path = "/home/user/DepthAnythingV2/models"
# Исходный файл
video_file_path = "/home/user/video.mkv"
video_file_name = os.path.splitext(os.path.basename(video_file_path))[0]
# Папка для выгрузки фреймов и папка для итоговых 3D фреймов
frames_path = os.path.join(os.path.dirname(video_file_path), f"{video_file_name}_frames")
images3d_path = os.path.join(os.path.dirname(video_file_path), f"{video_file_name}_3d")
os.makedirs(frames_path, exist_ok=True)
os.makedirs(images3d_path, 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 = 1 # 1 или 2
INPAINT_RADIUS = 2 # Для PARALLAX_METHOD = 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
depth_models_config = {
'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]}
}
# Выбор модели DepthAnythingV2: vits - Small, vitb - Base, vitl - Large
encoder = "vitl" # vits, vitb, vitl
model_depth_current = os.path.join(depth_models_path, f'depth_anything_v2_{encoder}.pth')
model_depth = DepthAnythingV2(**depth_models_config[encoder])
model_depth.load_state_dict(torch.load(model_depth_current, 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), dtype=np.int32)
# Подготовка изображений
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_path]
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_path]
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
# Если оба варианта не сработали, возвращаем None
print("Ошибка, не удалось определить исходное количество фреймов.")
return None
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_path = os.path.join(frames_path, f"file_%06d.png")
cmd = [
"ffmpeg", "-hwaccel", "cuda", "-i", video_file_path,
"-vf", f"select='between(n,{chunk_start},{chunk_end})'",
"-vsync", "0", "-start_number", str(chunk_start), extract_frames_path
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
print(cmd)
for i in range(chunk_end - chunk_start + 1):
frame_number = chunk_start + i
frame_path = extract_frames_path % 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_path, 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 isinstance(total_frames, int):
with ThreadPoolExecutor(max_workers=max_threads) as executor:
futures = []
for start_frame in range(0, total_frames, chunk_size):
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()
print("ГОТОВО.")
else:
print("Сначала нужно определить значение total_frames.")
# ЗАПУСК ОБРАБОТКИ
run_processing()
# Выгружаем модель и очищаем кеш 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/upgrade не помогло, стабильных версий оказалось не достаточно). Пришлось компилировать вручную последний ffmpeg (хотя лично меня вполне устраивал последний стабильный), и еще несколько других библиотек, но VapourSynth так и не получилось завести. Позже я обязательно вернусь к этому, когда будет больше свободного времени. Возможно под Windows это дело заводится проще.
Важный момент по обработке на лету. С одной стороны это удобно, с другой - есть нюансы. Обработка одного фильма на модели Depth-Anything-V2 Large может длиться больше суток, а то и несколько. Я приводил приблизительный расчет для фильма StarWars4 формата FullHD продолжительностью 2 часа 4 минуты в основной статье. На моей конфигурации (AMD Ryzen 5 PRO 3600, 32Gb DDR4, RTX 3060 12GB) с моделью Large потребовалось бы порядка 32 часов на обработку данного фильма, и если в процессе произойдет сбой или случайно выключится компьютер - придется начинать все сначала.

Другой момент. Надо быть точно уверенным в исходниках. В статье про апскейл старых видео я подробно описал, какие проблемы могут возникнуть при работе с некоторыми исходниками и форматами, особенно если это DVD-MPEG2 или что-то другое из старых времен. Там могут быть проблемы с точным определением частоты кадров, с выходным форматом изображения и чем угодно еще. Это нужно учитывать и предварительно проверять исходники и то, что из них получается на выходе.
А так, в целом, реализация с сервером обработки без сохранения фреймов - замечательная идея, ведь здесь совсем не требуется место под фреймы на диске, а если мы работаем с форматом 4K и PNG, это очень критичный момент.
Другая библиотека для конвертации 2D -> 3D
К комментариях также подсказали другое возможное решение.
Я его не пробовал, лишь бегло посмотрел. Там есть GUI и очень много настроек. Можно выбрать модель глубины, метод обработки, есть поддержка анаглифа и много чего еще. Пожалуй в этой реализации будет сложнее разобраться, но решение определенно заслуживает внимания.
Заключение
За сим я буду заканчивать с серией публикаций по теме конвертации видео из 2D в 3D. Еще раз напоминаю основную статью:
Как сделать 3D версию любого фильма на примере StarWars4 (DepthAnythingV2 + Parallax)
Там более подробно - как это работает, описание параметров, примеры изображений и тд.
Скрипты я периодически обновляю, за актуальными версиями можно следить тут, либо в Тг-канале.
На эксперименты я потратил несколько недель, просмотрел около десятка фильмов в 3D (это была исключительно приятная часть).
Вообще, изначально появилось желание "отдохнуть", отвлечься от основной деятельности (LLM, RAGи, агенты и их применение в различных задачах), переключиться на что-то более "легкое" и приятное. В итоге отдых перешел в практическое русло и получилось реализовать 2 pet-проекта - конвертацию видео 2D -> 3D, и апскейл старых видео (статья об этом). Параллельно пришлось глубже изучить незаменимый ffmpeg, погрузиться в некоторые форматы видео, немного в структуру используемых нейронок, познакомиться с принципом эффекта параллакса и тд тд. В общем, полезный получился отдых :-)

