Крутим Fisheye камеру в синтезированном мире
Крутим Fisheye камеру в синтезированном мире

Всем привет!

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

Проецирование объекта на матрицу в pinhole камере
Проецирование объекта на матрицу в 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 (рыбий глаз)

Проецирование объекта на матрицу в fisheye камере
Проецирование объекта на матрицу в 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. Пишем перепроецирование.

Для того чтоб перейти от изображения в одной проекции к изображению в другой делаем шаги

  1. Для каждой точки на конечной проекции ищем соответствующую точку на единичной сфере

  2. Для каждой из этих точек на сфере ищем точку на исходной проекции. Получаем карту откуда брать точку на исходной проекции для перевода в конечную

  3. Сохраняем и применяем эту карту. Код выглядит примерно так:

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) и умножения на матрицу перехода . Таким образом практически без усилий можно получить узел который не только позволяет перепроецировать изображения, но и поворачивать их

Поворот картинки в pinhole проекции
Поворот картинки в pinhole проекции

Можено при повороте также возвращать маску с незаполненными регионами картинки.

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

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

Заметим, что по факту мы выходим в 3д на каждом промежуточном шаге перепроецирования и если бы мы знали карту глубин, то вместо единичной сферы могли бы использовать ее для учета геометрии! Добавляем depth anything и все полетит? Конечно нет.

Идея 2. Глубина как патч‑оverlay

Чтобы добавить геометрию, нам нужна метрика расстояний до каждой точки изображения. Самый простой путь — добавить карту глубины, а потом в том же узле Grid Sample заменить единичную сферу на «слой» из рэй‑depth. Но сразу возникает пара подводных камней:

  1. Существующие узлы в ComfyUI работают с относительной глубиной, нам же нужен MetricDepth - абсолютное значение глубины

  2. Z‑depth ≠ R‑depth.
    Большинство готовых моделей (MiDaS, ZoeDepth, DPT) возвращают z‑depth — расстояние вдоль оси камеры.
    Для геометрии нужна
    ray‑depth — длина луча от центра камеры до пикселя. Их легко перевести друг в друга.

  3. 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 с камерой

  1. RGB‑D → Point Cloud (узел Depth2PC, маска = «какие точки уже есть»).

  2. Сдвигаем/вращаем камеру (матрица 4×4, тот же узел, что для Grid).

  3. Проецируем на выбранную проекцию и разрешение.

  4. Маска → инпейтинг (любая SD/Flux‑workflow).

  5. Новая глубина, нормируем чтоб после запуска depth estimation значения в уже известных точках совпадали. Это необходимо для отсутствия разрывов

  6. Новые пиксели → точки → добавляем к облаку.

Повторяем, пока не надоест. После 3‑4 кругов сцена «закрывается» почти полностью.

Для удобства все эти действия объединены в один узел PointcloudEnricher

Чтобы показать движение, я сохраняю траекторию как список матриц 4x4 np.array.

PointCloud Inpaint Video
PointCloud Inpaint Video

Идея 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‑панорамы ещё никогда не были столь досягаемы!

Комментарии (0)