Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 147 additions & 46 deletions 193 python/examples/panorama_sfm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
"""

import argparse
import os
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from threading import Lock

import cv2
import numpy as np
Expand All @@ -17,18 +21,49 @@
from pycolmap import logging


@dataclass
class PanoRenderOptions:
num_steps_yaw: int
pitches_deg: Sequence[float]
hfov_deg: float
vfov_deg: float


PANO_RENDER_OPTIONS: dict[str, PanoRenderOptions] = {
"overlapping": PanoRenderOptions(
num_steps_yaw=4,
pitches_deg=(-35.0, 0.0, 35.0),
hfov_deg=90.0,
vfov_deg=90.0,
),
# Cubemap without top and bottom images.
"non-overlapping": PanoRenderOptions(
num_steps_yaw=4,
pitches_deg=(0.0,),
hfov_deg=90.0,
vfov_deg=90.0,
),
}


def create_virtual_camera(
pano_height: int, fov_deg: float = 90
pano_width: int,
pano_height: int,
hfov_deg: float,
vfov_deg: float,
) -> pycolmap.Camera:
"""Create a virtual perspective camera."""
image_size = int(pano_height * fov_deg / 180)
focal = image_size / (2 * np.tan(np.deg2rad(fov_deg) / 2))
return pycolmap.Camera.create(0, "PINHOLE", focal, image_size, image_size)
image_width = int(pano_width * hfov_deg / 360)
image_height = int(pano_height * vfov_deg / 180)
focal = image_width / (2 * np.tan(np.deg2rad(hfov_deg) / 2))
return pycolmap.Camera.create(
0, "SIMPLE_PINHOLE", focal, image_width, image_height
Comment thread
ahojnnes marked this conversation as resolved.
)


def get_virtual_camera_rays(camera: pycolmap.Camera) -> np.ndarray:
size = (camera.width, camera.height)
y, x = np.indices(size).astype(np.float32)
x, y = np.indices(size).astype(np.float32)
Comment thread
ahojnnes marked this conversation as resolved.
xy = np.column_stack([x.ravel(), y.ravel()])
# The center of the upper left most pixel has coordinate (0.5, 0.5)
xy += 0.5
Expand All @@ -53,7 +88,7 @@ def spherical_img_from_cam(image_size, rays_in_cam: np.ndarray) -> np.ndarray:


def get_virtual_rotations(
num_steps_yaw: int = 4, pitches_deg: Sequence[float] = (-35.0, 35.0)
num_steps_yaw: int, pitches_deg: Sequence[float]
) -> Sequence[np.ndarray]:
"""Get the relative rotations of the virtual cameras w.r.t. the panorama."""
# Assuming that the panos are approximately upright.
Expand Down Expand Up @@ -94,28 +129,47 @@ def create_pano_rig_config(
return pycolmap.RigConfig(cameras=rig_cameras)


def render_perspective_images(
pano_image_names: Sequence[str],
pano_image_dir: Path,
output_image_dir: Path,
mask_dir: Path,
) -> pycolmap.RigConfig:
cams_from_pano_rotation = get_virtual_rotations()
rig_config = create_pano_rig_config(cams_from_pano_rotation)
class PanoProcessor:
def __init__(
self,
pano_image_dir: Path,
output_image_dir: Path,
mask_dir: Path,
render_options: PanoRenderOptions,
):
self.render_options = render_options
self.pano_image_dir = pano_image_dir
self.output_image_dir = output_image_dir
self.mask_dir = mask_dir

self.cams_from_pano_rotation = get_virtual_rotations(
num_steps_yaw=render_options.num_steps_yaw,
pitches_deg=render_options.pitches_deg,
)
self.rig_config = create_pano_rig_config(self.cams_from_pano_rotation)

# We assign each pano pixel to the virtual camera with the closest center.
cam_centers_in_pano = np.einsum(
"nij,i->nj", cams_from_pano_rotation, [0, 0, 1]
)
# We assign each pano pixel to the virtual camera
# with the closest camera center.
self.cam_centers_in_pano = np.einsum(
"nij,i->nj", self.cams_from_pano_rotation, [0, 0, 1]
)

self._lock = Lock()

camera = pano_size = rays_in_cam = None
for pano_name in tqdm(pano_image_names):
pano_path = pano_image_dir / pano_name
# These are initialized on the first pano image
# to avoid recomputing the rays for each pano image.
self._camera = None
self._pano_size = None
self._rays_in_cam = None

def process(self, pano_name: str):
pano_path = self.pano_image_dir / pano_name
try:
pano_image = PIL.Image.open(pano_path)
except PIL.Image.UnidentifiedImageError:
logging.info(f"Skipping file {pano_path} as it cannot be read.")
continue
return

pano_exif = pano_image.getexif()
pano_image = np.asarray(pano_image)
gpsonly_exif = PIL.Image.Exif()
Expand All @@ -127,56 +181,94 @@ def render_perspective_images(
if pano_width != pano_height * 2:
raise ValueError("Only 360° panoramas are supported.")

if camera is None: # First image.
camera = create_virtual_camera(pano_height)
for rig_camera in rig_config.cameras:
rig_camera.camera = camera
pano_size = (pano_width, pano_height)
rays_in_cam = get_virtual_camera_rays(camera) # Precompute.
else:
if (pano_width, pano_height) != pano_size:
raise ValueError(
"Panoramas of different sizes are not supported."
with self._lock:
if self._camera is None: # First image, precompute rays once.
self._camera = create_virtual_camera(
pano_width=pano_width,
pano_height=pano_height,
hfov_deg=self.render_options.hfov_deg,
vfov_deg=self.render_options.vfov_deg,
)

for cam_idx, cam_from_pano_r in enumerate(cams_from_pano_rotation):
rays_in_pano = rays_in_cam @ cam_from_pano_r
xy_in_pano = spherical_img_from_cam(pano_size, rays_in_pano)
for rig_camera in self.rig_config.cameras:
rig_camera.camera = self._camera
self._pano_size = (pano_width, pano_height)
self._rays_in_cam = get_virtual_camera_rays(self._camera)
else: # Later images, verify consistent panoramas.
if (pano_width, pano_height) != self._pano_size:
raise ValueError(
"Panoramas of different sizes are not supported."
)

for cam_idx, cam_from_pano_r in enumerate(self.cams_from_pano_rotation):
rays_in_pano = self._rays_in_cam @ cam_from_pano_r
xy_in_pano = spherical_img_from_cam(self._pano_size, rays_in_pano)
xy_in_pano = xy_in_pano.reshape(
camera.width, camera.height, 2
self._camera.width, self._camera.height, 2
).astype(np.float32)
xy_in_pano -= 0.5 # COLMAP to OpenCV pixel origin.
image = cv2.remap(
pano_image,
*np.moveaxis(xy_in_pano, -1, 0),
*np.moveaxis(xy_in_pano, [0, 1, 2], [2, 1, 0]),
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_WRAP,
)
# We define a mask such that each pixel of the panorama has its
# features extracted only in a single virtual camera.
closest_camera = np.argmax(rays_in_pano @ cam_centers_in_pano.T, -1)
closest_camera = np.argmax(
rays_in_pano @ self.cam_centers_in_pano.T, -1
)
mask = (
((closest_camera == cam_idx) * 255)
.astype(np.uint8)
.reshape(camera.width, camera.height)
.reshape(self._camera.width, self._camera.height)
.transpose()
)

image_name = rig_config.cameras[cam_idx].image_prefix + pano_name
image_name = (
self.rig_config.cameras[cam_idx].image_prefix + pano_name
)
mask_name = f"{image_name}.png"

image_path = output_image_dir / image_name
image_path = self.output_image_dir / image_name
image_path.parent.mkdir(exist_ok=True, parents=True)
PIL.Image.fromarray(image).save(image_path, exif=gpsonly_exif)

mask_path = mask_dir / mask_name
mask_path = self.mask_dir / mask_name
mask_path.parent.mkdir(exist_ok=True, parents=True)
if not pycolmap.Bitmap.from_array(mask).write(mask_path):
raise RuntimeError(f"Cannot write {mask_path}")

return rig_config

def render_perspective_images(
pano_image_names: Sequence[str],
pano_image_dir: Path,
output_image_dir: Path,
mask_dir: Path,
render_options: PanoRenderOptions,
) -> pycolmap.RigConfig:
processor = PanoProcessor(
pano_image_dir, output_image_dir, mask_dir, render_options
)

num_panos = len(pano_image_names)
max_workers = min(32, (os.cpu_count() or 2) - 1)

with tqdm(total=num_panos) as pbar:
with ThreadPoolExecutor(max_workers=max_workers) as thread_pool:
futures = [
thread_pool.submit(processor.process, pano_name)
for pano_name in pano_image_names
]
for future in as_completed(futures):
future.result()
pbar.update(1)

return processor.rig_config


def run(args):
pycolmap.set_random_seed(0)

# Define the paths.
image_dir = args.output_path / "images"
mask_dir = args.output_path / "masks"
Expand All @@ -200,9 +292,13 @@ def run(args):
logging.info(f"Found {len(pano_image_names)} images in {pano_image_dir}.")

rig_config = render_perspective_images(
pano_image_names, pano_image_dir, image_dir, mask_dir
pano_image_names,
pano_image_dir,
image_dir,
mask_dir,
PANO_RENDER_OPTIONS[args.pano_render_type],
)
pycolmap.set_random_seed(0)

pycolmap.extract_features(
database_path,
image_dir,
Expand Down Expand Up @@ -251,4 +347,9 @@ def run(args):
default="sequential",
choices=["sequential", "exhaustive", "vocabtree", "spatial"],
)
parser.add_argument(
"--pano_render_type",
default="overlapping",
choices=list(PANO_RENDER_OPTIONS.keys()),
)
run(parser.parse_args())
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.