#!/usr/bin/env python3
"""Convert equirectangular panorama to 6 cubemap faces (vectorized).
Supports EXR (via OpenEXR) and standard image formats (via PIL).
NASA Deep Star Maps are distributed as EXR files with float16 HDR data.
Usage:
python3 convert_milkyway.py <input_path> <output_dir> [face_size]
Example:
python3 convert_milkyway.py starmap_2020_4k.exr ./output 2048
"""
import sys
import os
import numpy as np
from PIL import Image
def load_image(input_path):
"""Load an image as a float32 numpy array normalized to [0, 1].
Supports EXR (via OpenEXR) and any format PIL can handle (JPG, PNG, etc).
"""
ext = os.path.splitext(input_path)[1].lower()
if ext == '.exr':
import OpenEXR
f = OpenEXR.File(input_path)
channel_name = list(f.channels().keys())[0]
pixels = f.channels()[channel_name].pixels
src = pixels.astype(np.float32)
# Clamp to [0, 1] - EXR can have HDR values
src = np.clip(src, 0.0, 1.0)
print(f"Loaded EXR: {src.shape[1]}x{src.shape[0]}, "
f"channels={channel_name}, dtype={pixels.dtype}")
else:
img = Image.open(input_path)
src = np.array(img, dtype=np.float32) / 255.0
print(f"Loaded image: {src.shape[1]}x{src.shape[0]}, "
f"mode={img.mode}")
# Ensure 3 channels
if src.ndim == 2:
src = np.stack([src, src, src], axis=-1)
elif src.shape[2] > 3:
src = src[:, :, :3]
return src
def equirect_to_cubemap(input_path, output_dir, face_size=2048):
"""Convert equirectangular panorama to 6 cubemap face PNGs."""
src = load_image(input_path)
h, w = src.shape[:2]
os.makedirs(output_dir, exist_ok=True)
# Create coordinate grids once
u = np.linspace(-1, 1, face_size, dtype=np.float32)
v = np.linspace(-1, 1, face_size, dtype=np.float32)
uu, vv = np.meshgrid(u, v)
ones = np.ones_like(uu)
face_dirs = {
'xp': lambda: np.stack([ ones, -vv, -uu], axis=-1), # +X (right)
'xn': lambda: np.stack([-ones, -vv, uu], axis=-1), # -X (left)
'yp': lambda: np.stack([ uu, ones, vv], axis=-1), # +Y (top)
'yn': lambda: np.stack([ uu, -ones, -vv], axis=-1), # -Y (bottom)
'zp': lambda: np.stack([ uu, -vv, ones], axis=-1), # +Z (front)
'zn': lambda: np.stack([ -uu, -vv, -ones], axis=-1), # -Z (back)
}
for name, dir_func in face_dirs.items():
dirs = dir_func()
norms = np.linalg.norm(dirs, axis=-1, keepdims=True)
dirs = dirs / norms
# Convert direction vectors to equirectangular coordinates
theta = np.arctan2(dirs[..., 0], dirs[..., 2])
phi = np.arcsin(np.clip(dirs[..., 1], -1, 1))
# Map to texture coordinates
eq_u = (theta / np.pi + 1.0) * 0.5
eq_v = (phi / (np.pi * 0.5) + 1.0) * 0.5
# Sample from source image (nearest neighbor)
sx = (eq_u * (w - 1)).astype(np.int32) % w
sy = ((1.0 - eq_v) * (h - 1)).astype(np.int32) % h
face = src[sy, sx]
# Apply gamma correction for EXR (linear -> sRGB)
# NASA star maps are in linear color space
face_gamma = np.power(np.clip(face, 0, 1), 1.0 / 2.2)
out_img = Image.fromarray((face_gamma[:, :, :3] * 255).astype(np.uint8))
out_path = os.path.join(output_dir, f"milkyway_{name}.png")
out_img.save(out_path, optimize=True)
print(f"Saved {out_path} ({face_size}x{face_size})")
print(f"\nDone: 6 cubemap faces at {face_size}x{face_size}")
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <input_path> <output_dir> [face_size]")
sys.exit(1)
equirect_to_cubemap(
sys.argv[1],
sys.argv[2],
int(sys.argv[3]) if len(sys.argv) > 3 else 2048
)