OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / distribute / hypertps-win64 / scripts / convert_milkyway.py

115 lines · python ref:
#!/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
    )