Created
April 6, 2026 18:18
-
-
Save jamesu/deb5ca2fd2f8891fb04f3c41bd4183c9 to your computer and use it in GitHub Desktop.
Convert those nice panoramas to torque skybox format courtesy of chatgpt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import os | |
| import math | |
| import json | |
| import argparse | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| # ============================================================ | |
| # Equirectangular panorama -> skybox faces | |
| # | |
| # World convention used here: | |
| # +Y = forward | |
| # +Z = up | |
| # +X = right | |
| # | |
| # Built-in "torque_convention" layout: fucked up | |
| # | |
| # e.g: python sacrifice_kork.py -o fucking_hell --layout torque_convention -p skybox -s 2048 ~/Downloads/Epic_BlueSunset_EquiRect_flat.png | |
| # | |
| # This keeps the sampling physically correct in world space, | |
| # then applies the engine-facing image orientation correction. | |
| # ============================================================ | |
| AXIS_NAMES = { | |
| (1, 0, 0): "+X", | |
| (-1, 0, 0): "-X", | |
| (0, 1, 0): "+Y", | |
| (0, -1, 0): "-Y", | |
| (0, 0, 1): "+Z", | |
| (0, 0, -1): "-Z", | |
| } | |
| def axis_name(vec): | |
| return AXIS_NAMES.get(tuple(int(v) for v in vec), str(vec)) | |
| def normalize(v): | |
| v = np.asarray(v, dtype=np.float32) | |
| n = np.linalg.norm(v) | |
| if n == 0: | |
| raise ValueError("zero-length vector") | |
| return v / n | |
| LAYOUTS = { | |
| "standard_zup_yfwd": [ | |
| {"name": "1", "face": "+X", "center": [1, 0, 0], "right": [0, -1, 0], "up": [0, 0, 1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "2", "face": "-X", "center": [-1, 0, 0], "right": [0, 1, 0], "up": [0, 0, 1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "3", "face": "+Y", "center": [0, 1, 0], "right": [1, 0, 0], "up": [0, 0, 1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "4", "face": "-Y", "center": [0, -1, 0], "right": [-1, 0, 0], "up": [0, 0, 1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "5", "face": "+Z", "center": [0, 0, 1], "right": [1, 0, 0], "up": [0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "6", "face": "-Z", "center": [0, 0, -1], "right": [1, 0, 0], "up": [0, 1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| ], | |
| "standard_yup_zfwd": [ | |
| {"name": "1", "face": "+X", "center": [ 1, 0, 0], "right": [ 0, 0, -1], "up": [ 0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "2", "face": "-X", "center": [-1, 0, 0], "right": [ 0, 0, 1], "up": [ 0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "3", "face": "+Y", "center": [ 0, 1, 0], "right": [ 1, 0, 0], "up": [ 0, 0, 1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "4", "face": "-Y", "center": [ 0, -1, 0], "right": [ 1, 0, 0], "up": [ 0, 0, -1], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "5", "face": "+Z", "center": [ 0, 0, 1], "right": [ 1, 0, 0], "up": [ 0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| {"name": "6", "face": "-Z", "center": [ 0, 0, -1], "right": [-1, 0, 0], "up": [ 0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": False}, | |
| ], | |
| # Likely Torque-facing convention: | |
| # slot 1 right, slot 2 left, slot 3 back, slot 4 front, slot 5 up, slot 6 down | |
| "torque_convention": [ | |
| {"name": "1", "face": "+X", "center": [1, 0, 0], "right": [0, -1, 0], "up": [0, 0, 1], "rotate": 270, "flip_x": True, "flip_y": False}, | |
| {"name": "2", "face": "-X", "center": [-1, 0, 0], "right": [0, 1, 0], "up": [0, 0, 1], "rotate": 90, "flip_x": True, "flip_y": False}, | |
| {"name": "3", "face": "+Y", "center": [0, 1, 0], "right": [1, 0, 0], "up": [0, 0, 1], "rotate": 180, "flip_x": True, "flip_y": False}, | |
| {"name": "4", "face": "-Y", "center": [0, -1, 0], "right": [-1, 0, 0], "up": [0, 0, 1], "rotate": 0, "flip_x": True, "flip_y": False}, | |
| {"name": "5", "face": "+Z", "center": [0, 0, 1], "right": [1, 0, 0], "up": [0, -1, 0], "rotate": 0, "flip_x": False, "flip_y": True}, | |
| {"name": "6", "face": "-Z", "center": [0, 0, -1], "right": [1, 0, 0], "up": [0, 1, 0], "rotate": 0, "flip_x": True, "flip_y": False}, | |
| ] | |
| } | |
| def bilinear_sample(image, x, y): | |
| h, w, _ = image.shape | |
| x = np.mod(x, w) | |
| y = np.clip(y, 0, h - 1) | |
| x0 = np.floor(x).astype(np.int32) | |
| y0 = np.floor(y).astype(np.int32) | |
| x1 = (x0 + 1) % w | |
| y1 = np.clip(y0 + 1, 0, h - 1) | |
| wx = (x - x0)[..., None] | |
| wy = (y - y0)[..., None] | |
| top = image[y0, x0] * (1.0 - wx) + image[y0, x1] * wx | |
| bottom = image[y1, x0] * (1.0 - wx) + image[y1, x1] * wx | |
| return top * (1.0 - wy) + bottom * wy | |
| def direction_to_equirect(dir_vec, width, height, yaw_offset_rad=0.0): | |
| x = dir_vec[..., 0] | |
| y = dir_vec[..., 1] | |
| z = dir_vec[..., 2] | |
| lon = np.arctan2(x, y) + yaw_offset_rad | |
| lat = np.arcsin(np.clip(z, -1.0, 1.0)) | |
| lon = (lon + math.pi) % (2.0 * math.pi) - math.pi | |
| px = (lon / (2.0 * math.pi) + 0.5) * width | |
| py = (0.5 - lat / math.pi) * height | |
| return px, py | |
| def generate_face_from_spec(src, face_spec, face_size, yaw_offset_rad=0.0): | |
| h, w, _ = src.shape | |
| i = np.arange(face_size, dtype=np.float32) | |
| j = np.arange(face_size, dtype=np.float32) | |
| ii, jj = np.meshgrid(i, j) | |
| u = 2.0 * ((ii + 0.5) / face_size) - 1.0 | |
| v = 1.0 - 2.0 * ((jj + 0.5) / face_size) | |
| center = normalize(face_spec["center"]) | |
| right = normalize(face_spec["right"]) | |
| up = normalize(face_spec["up"]) | |
| dir_vec = ( | |
| center[None, None, :] | |
| + u[..., None] * right[None, None, :] | |
| + v[..., None] * up[None, None, :] | |
| ) | |
| dir_vec /= np.linalg.norm(dir_vec, axis=-1, keepdims=True) | |
| px, py = direction_to_equirect(dir_vec, w, h, yaw_offset_rad) | |
| sampled = bilinear_sample(src, px, py) | |
| sampled = np.clip(sampled, 0, 255).astype(np.uint8) | |
| return Image.fromarray(sampled, mode="RGB") | |
| def apply_post_transform(img, rotate=0, flip_x=False, flip_y=False): | |
| rotate = rotate % 360 | |
| if rotate == 90: | |
| img = img.transpose(Image.Transpose.ROTATE_90) | |
| elif rotate == 180: | |
| img = img.transpose(Image.Transpose.ROTATE_180) | |
| elif rotate == 270: | |
| img = img.transpose(Image.Transpose.ROTATE_270) | |
| elif rotate != 0: | |
| raise ValueError("rotate must be 0, 90, 180, or 270") | |
| if flip_x: | |
| img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) | |
| if flip_y: | |
| img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) | |
| return img | |
| def make_debug_face(face_spec, face_size): | |
| img = Image.new("RGB", (face_size, face_size), (28, 28, 34)) | |
| draw = ImageDraw.Draw(img) | |
| try: | |
| font_big = ImageFont.truetype("Arial.ttf", size=max(24, face_size // 16)) | |
| font_small = ImageFont.truetype("Arial.ttf", size=max(16, face_size // 28)) | |
| except Exception: | |
| font_big = ImageFont.load_default() | |
| font_small = ImageFont.load_default() | |
| w = face_size | |
| h = face_size | |
| cx = w // 2 | |
| cy = h // 2 | |
| draw.rectangle((0, 0, w - 1, h - 1), outline=(230, 230, 230), width=3) | |
| draw.line((cx, 0, cx, h), fill=(90, 90, 90), width=1) | |
| draw.line((0, cy, w, cy), fill=(90, 90, 90), width=1) | |
| lines = [ | |
| f"Texture {face_spec['name']} ({face_spec['face']})", | |
| f"center: {axis_name(face_spec['center'])}", | |
| f"right: {axis_name(face_spec['right'])}", | |
| f"up: {axis_name(face_spec['up'])}", | |
| f"rotate: {face_spec.get('rotate', 0)}", | |
| f"flip_x: {face_spec.get('flip_x', False)}", | |
| f"flip_y: {face_spec.get('flip_y', False)}", | |
| ] | |
| y = 16 | |
| for idx, line in enumerate(lines): | |
| draw.text( | |
| (16, y), | |
| line, | |
| fill=(255, 230, 80) if idx == 0 else (255, 255, 255), | |
| font=font_big if idx == 0 else font_small, | |
| ) | |
| y += 30 if idx == 0 else 22 | |
| arrow_len = int(face_size * 0.22) | |
| draw.line((cx, cy, cx + arrow_len, cy), fill=(255, 90, 90), width=4) | |
| draw.polygon( | |
| [(cx + arrow_len, cy), (cx + arrow_len - 14, cy - 8), (cx + arrow_len - 14, cy + 8)], | |
| fill=(255, 90, 90), | |
| ) | |
| draw.text((cx + arrow_len + 10, cy - 10), axis_name(face_spec["right"]), fill=(255, 90, 90), font=font_small) | |
| draw.line((cx, cy, cx, cy - arrow_len), fill=(90, 255, 90), width=4) | |
| draw.polygon( | |
| [(cx, cy - arrow_len), (cx - 8, cy - arrow_len + 14), (cx + 8, cy - arrow_len + 14)], | |
| fill=(90, 255, 90), | |
| ) | |
| draw.text((cx + 10, cy - arrow_len - 22), axis_name(face_spec["up"]), fill=(90, 255, 90), font=font_small) | |
| r = max(28, face_size // 14) | |
| draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=(70, 80, 180), outline=(255, 255, 255), width=2) | |
| label = axis_name(face_spec["center"]) | |
| bbox = draw.textbbox((0, 0), label, font=font_small) | |
| tw = bbox[2] - bbox[0] | |
| th = bbox[3] - bbox[1] | |
| draw.text((cx - tw // 2, cy - th // 2), label, fill=(255, 255, 255), font=font_small) | |
| img = apply_post_transform( | |
| img, | |
| rotate=face_spec.get("rotate", 0), | |
| flip_x=face_spec.get("flip_x", False), | |
| flip_y=face_spec.get("flip_y", False), | |
| ) | |
| return img | |
| def load_custom_layout(path): | |
| with open(path, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| if not isinstance(data, list) or len(data) != 6: | |
| raise ValueError("custom layout JSON must be a list of 6 face objects") | |
| required = {"name", "face", "center", "right", "up"} | |
| for idx, face in enumerate(data): | |
| missing = required - set(face.keys()) | |
| if missing: | |
| raise ValueError(f"face {idx} missing required keys: {sorted(missing)}") | |
| return data | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Convert an equirectangular panorama into 6 skybox faces." | |
| ) | |
| parser.add_argument("input", nargs="?", help="Path to equirectangular input image") | |
| parser.add_argument("-o", "--output-dir", default="cubemap_out", help="Output directory") | |
| parser.add_argument("-p", "--prefix", default="skybox", help="Output filename prefix") | |
| parser.add_argument("-s", "--face-size", type=int, default=None, help="Face size in pixels; default=input_width/4") | |
| parser.add_argument("--yaw", type=float, default=0.0, help="Horizontal panorama yaw offset in degrees") | |
| parser.add_argument("--layout", choices=sorted(LAYOUTS.keys()), default="torque_convention", help="Built-in layout") | |
| parser.add_argument("--custom-layout", default=None, help="Custom layout JSON") | |
| parser.add_argument("--debug-labels", action="store_true", help="Generate labeled debug faces instead of sampling") | |
| parser.add_argument("--debug-overlay", action="store_true", help="Overlay debug labels on sampled faces") | |
| args = parser.parse_args() | |
| if args.custom_layout: | |
| layout = load_custom_layout(args.custom_layout) | |
| layout_name = "custom" | |
| else: | |
| layout = LAYOUTS[args.layout] | |
| layout_name = args.layout | |
| if not args.debug_labels and not args.input: | |
| raise SystemExit("input image is required unless --debug-labels is used") | |
| src = None | |
| face_size = args.face_size | |
| if args.input: | |
| input_path = Path(args.input) | |
| if not input_path.exists(): | |
| raise FileNotFoundError(f"input file not found: {input_path}") | |
| img = Image.open(input_path).convert("RGB") | |
| src = np.array(img, dtype=np.float32) | |
| h, w, _ = src.shape | |
| if face_size is None: | |
| face_size = w // 4 | |
| print(f"Input: {input_path}") | |
| print(f"Resolution: {w}x{h}") | |
| print(f"Face size: {face_size}") | |
| else: | |
| if face_size is None: | |
| face_size = 1024 | |
| print("Debug-only mode") | |
| print(f"Face size: {face_size}") | |
| print(f"Layout: {layout_name}") | |
| print(f"Output dir: {args.output_dir}") | |
| print(f"Prefix: {args.prefix}") | |
| print(f"Yaw: {args.yaw} degrees") | |
| print() | |
| for face in layout: | |
| print( | |
| f"{face['name']}: " | |
| f"face={face['face']} " | |
| f"center={axis_name(face['center'])} " | |
| f"right={axis_name(face['right'])} " | |
| f"up={axis_name(face['up'])} " | |
| f"rotate={face.get('rotate', 0)} " | |
| f"flip_x={face.get('flip_x', False)} " | |
| f"flip_y={face.get('flip_y', False)}" | |
| ) | |
| print() | |
| os.makedirs(args.output_dir, exist_ok=True) | |
| yaw_offset_rad = math.radians(args.yaw) | |
| for face_spec in layout: | |
| out_path = Path(args.output_dir) / f"{args.prefix}_{face_spec['name']}.png" | |
| if args.debug_labels: | |
| out = make_debug_face(face_spec, face_size) | |
| else: | |
| out = generate_face_from_spec(src, face_spec, face_size, yaw_offset_rad) | |
| out = apply_post_transform( | |
| out, | |
| rotate=face_spec.get("rotate", 0), | |
| flip_x=face_spec.get("flip_x", False), | |
| flip_y=face_spec.get("flip_y", False), | |
| ) | |
| if args.debug_overlay: | |
| overlay = make_debug_face(face_spec, face_size).convert("RGBA") | |
| base = out.convert("RGBA") | |
| overlay.putalpha(85) | |
| base.alpha_composite(overlay) | |
| out = base.convert("RGB") | |
| out.save(out_path) | |
| print(f"Saved: {out_path}") | |
| print("\nDone.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment