Skip to content

Instantly share code, notes, and snippets.

@jamesu
Created April 6, 2026 18:18
Show Gist options
  • Select an option

  • Save jamesu/deb5ca2fd2f8891fb04f3c41bd4183c9 to your computer and use it in GitHub Desktop.

Select an option

Save jamesu/deb5ca2fd2f8891fb04f3c41bd4183c9 to your computer and use it in GitHub Desktop.
Convert those nice panoramas to torque skybox format courtesy of chatgpt
#!/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