
Всем привет!
Я много работаю с видео для виртуальной реальности, и одна из задач, которая всё ещё маячит на горизонте и требует уймы усилий — удобное создание и стриминг полноценного 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сохраняет numpy- N×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‑панорамы ещё никогда не были столь досягаемы!
 
          