SplatAnything / src /utils /panorama.py
ritianyu's picture
Publish preview-first SplatAnything demo for ZeroGPU
dc09dcc verified
Raw History Blame Contribute Delete
6.44 kB
"""Equirectangular panoramas to calibrated pinhole views, using NumPy and Pillow."""
import json
import math
from pathlib import Path
import shutil
import tempfile
import warnings
import numpy as np
from PIL import Image
from .camera_io import write_cameras_json
def view_cameras(size=1022, fov=60.0):
"""Match the bundled panorama layout: equator, downward ring, upward ring."""
if not isinstance(size, int) or size < 2:
raise ValueError('View size must be an integer of at least 2 pixels.')
if not math.isfinite(fov) or not 0 < fov < 180:
raise ValueError('Field of view must be finite and between 0 and 180 degrees.')
focal = size / (2 * math.tan(math.radians(fov) / 2))
intrinsic = np.array([[focal, 0, (size - 1) / 2],
[0, focal, (size - 1) / 2], [0, 0, 1]], dtype=np.float64)
result = []
for pitch in (0, -45, 45):
p = math.radians(pitch)
rx = np.array([[1, 0, 0], [0, math.cos(p), -math.sin(p)],
[0, math.sin(p), math.cos(p)]])
for yaw in range(0, 360, 45):
y = math.radians(yaw)
ry = np.array([[math.cos(y), 0, math.sin(y)], [0, 1, 0],
[-math.sin(y), 0, math.cos(y)]])
pose = np.eye(4)
pose[:3, :3] = ry @ rx
result.append((f'frame_{len(result):04d}', yaw, pitch, pose, intrinsic.copy()))
return result
def perspective_view(panorama, pose, intrinsic, size, chunk_rows=64):
"""Bilinear sampling; wrap longitude and clamp latitude at the poles."""
height, width, _ = panorama.shape
output = np.empty((size, size, 3), dtype=np.uint8)
x = (np.arange(size, dtype=np.float64) - intrinsic[0, 2]) / intrinsic[0, 0]
for start in range(0, size, chunk_rows):
stop = min(start + chunk_rows, size)
y = (np.arange(start, stop, dtype=np.float64) - intrinsic[1, 2]) / intrinsic[1, 1]
xx, yy = np.meshgrid(x, y)
rays = np.stack((xx, yy, np.ones_like(xx)), axis=-1) @ pose[:3, :3].T
longitude = np.arctan2(rays[..., 0], rays[..., 2])
latitude = np.arctan2(-rays[..., 1], np.hypot(rays[..., 0], rays[..., 2]))
u = (longitude / (2 * np.pi) + .5) * width - .5
v = np.clip((.5 - latitude / np.pi) * height - .5, 0, height - 1)
x0 = np.floor(u).astype(np.int64)
y0 = np.floor(v).astype(np.int64)
wx, wy = (u - x0)[..., None], (v - y0)[..., None]
top = panorama[y0, x0 % width] * (1 - wx) + panorama[y0, (x0 + 1) % width] * wx
bottom_y = np.minimum(y0 + 1, height - 1)
bottom = panorama[bottom_y, x0 % width] * (1 - wx) + panorama[bottom_y, (x0 + 1) % width] * wx
output[start:stop] = np.clip(np.rint(top * (1 - wy) + bottom * wy), 0, 255).astype(np.uint8)
return output
def convert_panorama(source, output, size=1022, fov=60.0, overwrite=False):
"""Stage a complete scene before replacing only the three managed outputs."""
cameras = view_cameras(size, fov)
source, output = Path(source).expanduser().resolve(), Path(output).expanduser().resolve()
names = ('images', 'prior_camera.json', 'panorama.json')
if source == output / 'images' or output / 'images' in source.parents:
raise ValueError('Keep the source panorama outside the managed output images/ directory.')
if not source.is_file():
raise FileNotFoundError(source)
existing = [output / name for name in names if (output / name).exists() or (output / name).is_symlink()]
if existing and not overwrite:
raise FileExistsError(f'Output already exists: {existing[0]}. Use --overwrite to replace generated files.')
with warnings.catch_warnings():
# 16K ERP images exceed Pillow's warning threshold but remain below its error limit.
warnings.simplefilter('ignore', Image.DecompressionBombWarning)
with Image.open(source) as image:
if image.width != 2 * image.height:
raise ValueError('Expected a full 360 x 180 degree equirectangular image with a 2:1 aspect ratio.')
if image.getexif().get(274, 1) != 1:
raise ValueError('Apply EXIF orientation to the panorama before conversion.')
panorama = np.asarray(image.convert('RGB'))
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.pano-', dir=output) as temporary:
stage = Path(temporary)
(stage / 'images').mkdir()
poses, intrinsics, views = {}, {}, []
for camera_id, yaw, pitch, pose, intrinsic in cameras:
image_path = stage / 'images' / f'{camera_id}.png'
Image.fromarray(perspective_view(panorama, pose, intrinsic, size)).save(image_path)
poses[camera_id], intrinsics[camera_id] = pose, intrinsic
views.append(dict(camera_id=camera_id, image=f'images/{camera_id}.png',
yaw_degrees=yaw, pitch_degrees=pitch))
write_cameras_json(stage / 'prior_camera.json', sorted((stage / 'images').glob('*.png')), poses, intrinsics)
metadata = dict(source=source.name, source_size=[panorama.shape[1], panorama.shape[0]],
view_size=[size, size], fov_degrees=fov,
camera_convention='OpenCV camera-to-world: +X right, +Y down, +Z forward',
panorama_convention='Center looks along +Z; positive yaw turns right; positive pitch looks up',
views=views)
(stage / 'panorama.json').write_text(json.dumps(metadata, indent=2) + '\n')
# Keep originals and unrelated files. Restore previous outputs if installation fails.
saved, installed = [], []
try:
for name in names:
target = output / name
if target.exists() or target.is_symlink():
target.rename(stage / f'old-{name}')
saved.append(name)
(stage / name).rename(target)
installed.append(name)
except BaseException:
for name in reversed(installed):
target = output / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
else:
target.unlink()
for name in saved:
(stage / f'old-{name}').rename(output / name)
raise
return output