
Всем привет!
Я много работаю с видео для виртуальной реальности, и одна из задач, которая всё ещё маячит на горизонте и требует уймы усилий — удобное создание и стриминг полноценного 6Dof видео. Большинство существующих решений сводятся к двум крупным направлениям.
Многокамерная реконструкция. Тут живут SFM, классические меш‑генераторы, nerf, gaussian splatting — и все варианты хранения результатов. Эти методы отлично работают для статичных сцен, но с видео пока справляются лишь немногие. Кроме того требуется большое количество камер и нестандартные сетапы для съемки
Чистая генерация видео. Диффузионные модели быстро учатся рисовать динамику. Свежий пример — Veo 3 способен сносно генерировать equirect видео (https://www.youtube.com/watch?v=OjaupTb7S-w) с небольшим количеством артефактов
В этой статье мы рассмотрим, как расширить возможности генеративных моделей для виртуальной реальности (VR), создав модуль для ComfyUI, который позволит:
преобразовывать изображения и видео между pinhole, fisheye и equirectangular проекциями;
итеративно аутпейнтить панорамы до полного сферического охвата;
синтезировать новые ракурсы свободным движением камеры в 3‑D‑пространстве.
Я покажy, как объединить продвинутый reprojection (grid_sample + точки на сфере) с картами картами глубины и облаками точек, чтобы получить реалистичные «новые виды» из одного изображения. Кроме того совместим подход с геометрией хорошо совместим с подходом Video Generation
Типы камер и проекций: pinhole, fisheye, equirect
Pinhole (камера обскура)

d = 1/tan(fov/2)
r = x² + y²
theta = atan2(r, d) # angle from center
phi = atan2(y, x)
Z = R cos(theta)
X = R sin(theta) cos(phi)
Y = R sin(theta) * sin(phi)
Несмотря на сложную систему линз в современных камерах, большинство камер можно описать этой моделью и это привычные для нас изображения. Что важно, такой тип изображений наиболее часто используется и для обучения vision и генеративных моделей. Это важно, потому что по умолчанию предполагается что геометрия на проекции должна быть такой. Обычно FOV не превышает 60 градусов, иногда до 90. Если больше - становится видно искажения по краям и уменьшения центра. Математически невозможно спроецировать таким образом изображение с FOV равным или больше 180 градусов
Fisheye (рыбий глаз)

В этом типе проекции радиальное расстояние пропорционально углу
r = x² + y²
theta = r *(fov / 2) # angle from center
phi = atan2(y, x)
Z = R*cos(theta)
X = R*sin(theta)*cos(phi)
Y = R sin(theta) sin(phi)
Поддерживает до 360° FOV, но искажает прямые линии. Хотя здесь нет матеметического ограничения по углу, есть проблема с тем как сделать линзу чтоб она искажала входные лучи таким образом, чтоб r на проекции был пропорционален углу. Нелинейность популярных fisheye объективов и как ее учесть хорошо описана тут http://www.paulbourke.net/dome/fisheyecorrect/ . Но так как я работаю с математической моделью а не настоящими проекциями, проблемы нелинейности в проекте нет
Equirectangular (Равнопромежуточная ) проекция

Это математическое отображение сферических координат theta, phi на прямоугольник, многим знакомое еще с уроков школьной географии.
phi = x (horizontal_fov / 2)
theta = y (vertical_fov / 2)
X = R cos(theta) sin(phi)/
Y = R sin(theta)
Z = R cos(theta) * cos(phi)
Де-факто является стандартом для панорам (360° и 180°) и VR видео. Этой проекции не соответствует физического объектива.
Идея 1. Пишем перепроецирование.
Для того чтоб перейти от изображения в одной проекции к изображению в другой делаем шаги
Для каждой точки на конечной проекции ищем соответствующую точку на единичной сфере
Для каждой из этих точек на сфере ищем точку на исходной проекции. Получаем карту откуда брать точку на исходной проекции для перевода в конечную
Сохраняем и применяем эту карту. Код выглядит примерно так:
def map_grid(
grid_torch: torch.Tensor,
input_projection: str,
output_projection: str,
input_horizontal_fov: float,
output_horizontal_fov: float,
rotation_matrix: torch.Tensor = None
) -> np.ndarray:
"""
Maps a 2D grid from one projection type to another.
Args:
grid_torch (torch.Tensor): A 2D array of shape (height, width, 2) with x and y coordinates normalized to [-1, 1].
input_projection (str): The input projection type ("PINHOLE", "FISHEYE", "EQUIRECTANGULAR").
output_projection (str): The output projection type ("PINHOLE", "FISHEYE", "EQUIRECTANGULAR").
input_horizontal_fov (float): Horizontal field of view for the input projection in degrees.
output_horizontal_fov (float): Horizontal field of view for the output projection in degrees.
rotation_matrix (torch.Tensor, optional): A 4x4 rotation matrix. Defaults to identity matrix if not provided.
Returns:
np.ndarray: A 2D array of shape (height, width, 2) with the mapped x and y coordinates.
"""
with torch.no_grad():
if rotation_matrix is None:
rotation_matrix = torch.eye(4) # Identity matrix
rotation_matrix = rotation_matrix.float().to(grid_torch.device)
# convert all floats to tensors
input_horizontal_fov = torch.tensor(input_horizontal_fov, device=grid_torch.device).float()
output_horizontal_fov = torch.tensor(output_horizontal_fov, device=grid_torch.device).float()
# Calculate vertical field of view for input and output projections
output_vertical_fov = output_horizontal_fov # Assuming square aspect ratio
input_vertical_fov = input_horizontal_fov * (grid_torch.shape[0] / grid_torch.shape[1])
# Normalize the grid for vertical FOV adjustment
normalized_grid = grid_torch.clone()
normalized_grid[..., 1] *= (grid_torch.shape[0] / grid_torch.shape[1])
# Step 1: Map each pixel to its location on the sphere for the output projection
if output_projection == "PINHOLE":
D = 1.0 / torch.tan(torch.deg2rad(output_horizontal_fov) / 2)
radius_to_center = torch.sqrt(normalized_grid[..., 0]**2 + normalized_grid[..., 1]**2)
phi = torch.atan2(normalized_grid[..., 1], normalized_grid[..., 0])
theta = torch.atan2(radius_to_center, D)
x = torch.sin(theta) * torch.cos(phi)
y = torch.sin(theta) * torch.sin(phi)
z = torch.cos(theta)
elif output_projection == "FISHEYE":
phi = torch.atan2(normalized_grid[..., 1], normalized_grid[..., 0])
radius = torch.sqrt(normalized_grid[..., 0]**2 + normalized_grid[..., 1]**2)
theta = radius * torch.deg2rad(output_horizontal_fov) / 2
x = torch.sin(theta) * torch.cos(phi)
y = torch.sin(theta) * torch.sin(phi)
z = torch.cos(theta)
elif output_projection == "EQUIRECTANGULAR":
phi = grid_torch[..., 0] * torch.deg2rad(output_horizontal_fov) / 2
theta = grid_torch[..., 1] * torch.deg2rad(output_vertical_fov) / 2
y = torch.sin(theta)
x = torch.cos(theta) * torch.sin(phi)
z = torch.cos(theta) * torch.cos(phi)
else:
raise ValueError(f"Unsupported output projection: {output_projection}")
# Step 2: Apply rotation matrix for yaw and pitch
coords = torch.stack([x.flatten(), y.flatten(), z.flatten()], dim=-1)
coords_homogeneous = torch.cat([coords, torch.ones((coords.shape[0], 1), device=coords.device)], dim=-1)
coords_rotated = torch.matmul(rotation_matrix, coords_homogeneous.T).T
coords = coords_rotated[..., :3] # Extract x, y, z after rotation
# Step 3: Map rotated points back to the input projection
if input_projection == "PINHOLE":
D = 1.0 / torch.tan(torch.deg2rad(input_horizontal_fov) / 2)
theta = torch.atan2(torch.sqrt(coords[..., 0]**2 + coords[..., 1]**2), coords[..., 2])
phi = torch.atan2(coords[..., 1], coords[..., 0])
radius = D * torch.tan(theta)
x = radius * torch.cos(phi)
y = radius * torch.sin(phi)
mask = coords[..., 2] > 0 # Only keep points where z > 0
x[~mask] = 100
y[~mask] = 100
elif input_projection == "FISHEYE":
theta = torch.atan2(torch.sqrt(coords[..., 0]**2 + coords[..., 1]**2), coords[..., 2])
phi = torch.atan2(coords[..., 1], coords[..., 0])
radius = theta / (torch.deg2rad(input_horizontal_fov) / 2)
x = radius * torch.cos(phi)
y = radius * torch.sin(phi)
elif input_projection == "EQUIRECTANGULAR":
theta = torch.asin(coords[..., 1])
phi = torch.atan2(coords[..., 0], coords[..., 2])
x = phi / (torch.deg2rad(input_horizontal_fov) / 2)
y = theta / (torch.deg2rad(input_vertical_fov) / 2)
else:
raise ValueError(f"Unsupported input projection: {input_projection}")
x = x.view(grid_torch.shape[0], grid_torch.shape[1])
y = y.view(grid_torch.shape[0], grid_torch.shape[1])
output_grid = torch.zeros_like(grid_torch)
output_grid[..., 0] = x
output_grid[..., 1] = y
return output_grid
Можно обернуть перепроецирование в ComfyUI node
Замечаем что на шаге координатами XYZ легко проводить преобразования, которые соответсвуют движениям камеры путем перехода в однородные координаты (вектор XYZ переводим в XYZ1) и умножения на матрицу перехода . Таким образом практически без усилий можно получить узел который не только позволяет перепроецировать изображения, но и поворачивать их

Можено при повороте также возвращать маску с незаполненными регионами картинки.
Если хочется, то можно использовать Patch-based подход для итеративного заполнения пустых мест при повороте! Например пайплайн может выглядеть так:
Проецируем картинку в fisheye 180, далее патчами поворачиваем ее в Pinhole, делаем oupainting любой моделью, проецируем обратно. Вот как это выглядит с Flux:

ComfyUI workflow для всех экспериментов лежат в репозитории

Заметим, что по факту мы выходим в 3д на каждом промежуточном шаге перепроецирования и если бы мы знали карту глубин, то вместо единичной сферы могли бы использовать ее для учета геометрии! Добавляем depth anything и все полетит? Конечно нет.
Идея 2. Глубина как патч‑оverlay
Чтобы добавить геометрию, нам нужна метрика расстояний до каждой точки изображения. Самый простой путь — добавить карту глубины, а потом в том же узле Grid Sample заменить единичную сферу на «слой» из рэй‑depth. Но сразу возникает пара подводных камней:
Существующие узлы в ComfyUI работают с относительной глубиной, нам же нужен MetricDepth - абсолютное значение глубины
Z‑depth ≠ R‑depth.
Большинство готовых моделей (MiDaS, ZoeDepth, DPT) возвращают z‑depth — расстояние вдоль оси камеры.
Для геометрии нужна
ray‑depth — длина луча от центра камеры до пикселя. Их легко перевести друг в друга.-
DepthAnythingV2 умеет более‑менее уверенно работать лишь на угле ~90°. Поэтому изображение с большим FOV режется на перекрывающиеся тайлы, каждая тайла через DepthAnything →
Z‑to‑Ray
→ сборка финальной depth‑панорамы. При сборке я попробовал четыре стратегии: override и average даёт резкие ступени; soft‑blend (экспоненциальное затенение от центра к краям) ситуация чуть лучше, но всё равно видны швы; поэтому родился distance‑aware merge: вес каждого пикселя зависит от расстояния до края патча.Узлы связанные с определением глубины собраны тут
Идея 3. Точка лучше полигона — переходим в point cloud
С картой глубины мы впервые получаем геометрию.
Если посмотреть на код перепроецирования, то поймем что для того чтобы использовать эту геометрию, нам необходима глубина конечного изображения а не начального. grid_sample подход почти невозможно использовать для свободного движения камеры по сцене.
Поэтому можно отказаться от подхода grid_sample и использовать подход с расчетами от начального изображения к конечному, а не наоборот. Естественным образом переходим к подходу pointcloud - облако точек: каждая точка хранит XYZ + RGBA
. Для всех трёх проекций есть прямые формулы depth → XYZ
Pointcloud из себя представляет numpy array формы N*6 (XYZRGB). Также распространенным форматом хранения pointcloud является .ply - добавим i/o операции для numpy и ply.
I/O в PLY. Узел
PointCloud Save/Load
сохраняет numpyN×6
в формате PLY — любой просмотрщик (MeshLab, CloudCompare, Open3D) открывает без конверсий.Рендер на CUDA. Внутри узла
PointCloud Project
лежит минимальный оc‑рендер: проецируем каждую точку в пространство камеры, сортируем по‑пиксельно по глубине, оставляем «первое попадание» и получаемRGB проекцию + α‑маску
.Добавляем поворот pointcloud путем умножения однородных координат на матрицу поворота
-
Чистка. На переходах часто висят «одинокие» точки и зазоры. Помогает медианный фильтр по глубине + морфологическое закрытие маски; в репозитории скрипт
cloud_
clean.py
.Видим что при движении камеры и проецировании начинает учитываться глубина
Также полезно будет при проецировании pointcloud дополнительно возвращать глубину.
Почему не меш? Подход с мешами требует отдельной работы для детекции разрывов меша и пока не нашел приемлимых по качеству способов сделать это - вроде meta какие-то проекты на эту тему делали, но неудобно это все.
Идея 4. Обогащаем облако и двигаем камеру
Усложним workflow с камерой
RGB‑D → Point Cloud (узел
Depth2PC
, маска = «какие точки уже есть»).Сдвигаем/вращаем камеру (матрица
4×4
, тот же узел, что для Grid).Проецируем на выбранную проекцию и разрешение.
Маска → инпейтинг (любая SD/Flux‑workflow).
Новая глубина, нормируем чтоб после запуска depth estimation значения в уже известных точках совпадали. Это необходимо для отсутствия разрывов
Новые пиксели → точки → добавляем к облаку.
Повторяем, пока не надоест. После 3‑4 кругов сцена «закрывается» почти полностью.
Для удобства все эти действия объединены в один узел PointcloudEnricher
Чтобы показать движение, я сохраняю траекторию как список матриц 4x4 np.array.


Идея 5 Использование Видео генератора Wan для заполнения пустых мест при движении камеры.
Вместо итеративного обогащения облака точек можно использовать видеогенератор, например Wan2.1, недавно добавленный к базовым узлам ComfyUI. Таким образом берется качество и гладкость картинки от видеогенератора но при этом геометрия заведомо верна

Юзкейсы
VR180/360 конверсия. Из обычного кадра получаем стерео‑панораму или монопанораму без заметных швов.
Bullet‑time в одиночку. Ставите одну камеру, делаете оборот «облаком», добавляете motion‑blur во время рендеринга.
Точный equirect‑outpaint. Гарантированно верная геометрия горизонта + отсутствие «стянутости» на полюсах.
2d/3d конверсия для любой камеры и любого FOV

Репозиторий, рабочие графы для ComfyUI и полные скрипты очистки/рендера — в
https://github.com/Alexankharin/camera-comfyUI
. Pull‑request’ы и идеи приветствуются!
Что дальше?
Рефактор кода и убрать зависимость от flux
Gaussian Splatting - не уверен что будет время аккуратно добавить рендеринг
Продолжайте экспериментировать — VR‑панорамы ещё никогда не были столь досягаемы!