250 lines
8.2 KiB
Python
250 lines
8.2 KiB
Python
import argparse
|
|
import urllib.request
|
|
import os
|
|
import sys
|
|
import math
|
|
import ssl
|
|
from PIL import Image
|
|
|
|
# Ensure numpy is available
|
|
try:
|
|
import numpy as np
|
|
except ImportError:
|
|
print("Error: numpy is required. Please install it with: pip install numpy")
|
|
sys.exit(1)
|
|
|
|
# Default URLs
|
|
COLOR_URL = "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/lroc_color_2k.jpg"
|
|
DISP_URL = "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/ldem_4.tif"
|
|
|
|
SRC_COLOR_FILENAME = "lroc_color_2k.jpg"
|
|
SRC_DISP_FILENAME = "ldem_4.tif"
|
|
|
|
def download_file(url, filename):
|
|
if os.path.exists(filename):
|
|
print(f"File {filename} already exists. Skipping download.")
|
|
return
|
|
|
|
print(f"Downloading {url} to {filename}...")
|
|
# Create unverified context to avoid potential SSL cert issues
|
|
context = ssl._create_unverified_context()
|
|
try:
|
|
with urllib.request.urlopen(url, context=context) as response, open(filename, 'wb') as out_file:
|
|
data = response.read()
|
|
out_file.write(data)
|
|
print(f"Download of {filename} complete.")
|
|
except Exception as e:
|
|
print(f"Error downloading file {filename}: {e}")
|
|
# Don't exit hard if it's just one file, maybe?
|
|
# But for this script, we likely need it.
|
|
sys.exit(1)
|
|
|
|
def equirectangular_to_orthographic(src_img, size, mode=None):
|
|
"""
|
|
Reprojects an equirectangular image to an orthographic projection (sphere view).
|
|
src_img: PIL Image (Equirectangular)
|
|
size: Output size (width, height) - usually square
|
|
"""
|
|
print(f"Reprojecting to {size}x{size} Orthographic Disk...")
|
|
|
|
width, height = size, size
|
|
src_w, src_h = src_img.size
|
|
|
|
# Create coordinate grid centered at 0,0 (-1 to 1)
|
|
y, x = np.mgrid[size/2:-size/2:-1, -size/2:size/2] # Note: y goes high to low
|
|
|
|
# Normalize to -1..1
|
|
x = x / (size / 2)
|
|
y = y / (size / 2)
|
|
|
|
# Mask for points outside the circle
|
|
r2 = x*x + y*y
|
|
mask = r2 <= 1.0
|
|
|
|
# Calculate sphere coordinates (z > 0 for front face)
|
|
z = np.zeros_like(r2)
|
|
z[mask] = np.sqrt(1.0 - r2[mask])
|
|
|
|
# Vector P = (x, y, z) on unit sphere
|
|
# Lat = asin(y)
|
|
# Lon = atan2(x, z)
|
|
|
|
# Apply mask to avoid invalid calculations
|
|
lat = np.arcsin(y * mask)
|
|
lon = np.arctan2(x * mask, z * mask)
|
|
|
|
# Map to UV [0, 1]
|
|
u = (lon / (2 * math.pi)) + 0.5
|
|
v = (lat / math.pi) + 0.5
|
|
|
|
# Map to Source Pixels
|
|
u = np.clip(u, 0, 1)
|
|
v = np.clip(v, 0, 1)
|
|
|
|
src_x = (u * (src_w - 1)).astype(np.int32)
|
|
src_y = ((1.0 - v) * (src_h - 1)).astype(np.int32) # Flip V for image coords
|
|
|
|
# Sample pixels
|
|
src_array = np.array(src_img)
|
|
|
|
# Handle dimensions
|
|
if len(src_array.shape) == 2:
|
|
# Grayscale / Single channel
|
|
out_channels = 1
|
|
src_array = src_array[:, :, np.newaxis] # Expand for consistent indexing
|
|
else:
|
|
out_channels = src_array.shape[2]
|
|
|
|
out_array = np.zeros((height, width, out_channels), dtype=src_array.dtype)
|
|
|
|
# Advanced indexing
|
|
valid_y, valid_x = np.where(mask)
|
|
|
|
# Extract coordinates for valid pixels
|
|
sx = src_x[valid_y, valid_x]
|
|
sy = src_y[valid_y, valid_x]
|
|
|
|
out_array[valid_y, valid_x] = src_array[sy, sx]
|
|
|
|
# Squeeze if single channel
|
|
if out_channels == 1:
|
|
out_array = out_array.squeeze(axis=2)
|
|
|
|
return Image.fromarray(out_array, mode or src_img.mode)
|
|
|
|
def compute_normal_map(height_img, scale=1.0, blur_radius=0.0):
|
|
print("Computing Normal Map from Height Map...")
|
|
# Convert to float array
|
|
h = np.array(height_img).astype(np.float32)
|
|
|
|
# Apply Blur if requested
|
|
if blur_radius > 0:
|
|
try:
|
|
from scipy.ndimage import gaussian_filter
|
|
print(f"Applying Gaussian Blur (Radius: {blur_radius})...")
|
|
h = gaussian_filter(h, sigma=blur_radius)
|
|
except ImportError:
|
|
print("Warning: scipy not found. Skipping Gaussian Blur.")
|
|
|
|
# Normalize height to 0..1 for consistent gradient scale regardless of input depth
|
|
h_min, h_max = h.min(), h.max()
|
|
print(f"Height Map Range: {h_min} to {h_max}")
|
|
if h_max > h_min:
|
|
h_norm = (h - h_min) / (h_max - h_min)
|
|
else:
|
|
h_norm = h
|
|
|
|
# Gradients
|
|
dy, dx = np.gradient(h_norm)
|
|
|
|
# Pre-emphasis scale
|
|
bump_scale = scale
|
|
|
|
# Normal vector components
|
|
# Map is Top-Down Y.
|
|
nx = -dx * bump_scale
|
|
ny = -dy * bump_scale
|
|
nz = np.ones_like(nx)
|
|
|
|
# Mask out normals where r > 0.96 (avoid edge cliff artifacts)
|
|
rows, cols = h.shape
|
|
y, x = np.ogrid[:rows, :cols]
|
|
center_y, center_x = rows/2.0, cols/2.0
|
|
# max radius is size/2
|
|
radius_sq = (min(rows, cols) / 2.0)**2
|
|
dist_sq = (x - center_x)**2 + (y - center_y)**2
|
|
mask = dist_sq < (radius_sq * 0.96 * 0.96)
|
|
|
|
nx[~mask] = 0
|
|
ny[~mask] = 0
|
|
nz[~mask] = 1
|
|
|
|
# Normalize
|
|
len_n = np.sqrt(nx*nx + ny*ny + nz*nz)
|
|
# Avoid divide by zero
|
|
len_n[len_n == 0] = 1.0
|
|
|
|
nx /= len_n
|
|
ny /= len_n
|
|
nz /= len_n
|
|
|
|
# Pack to 0..255
|
|
# [-1, 1] -> [0, 255]
|
|
out_x = ((nx + 1.0) * 0.5 * 255.0).astype(np.uint8)
|
|
out_y = ((ny + 1.0) * 0.5 * 255.0).astype(np.uint8)
|
|
out_z = ((nz + 1.0) * 0.5 * 255.0).astype(np.uint8)
|
|
|
|
out_rgb = np.dstack((out_x, out_y, out_z))
|
|
return Image.fromarray(out_rgb, 'RGB')
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Process Moon Texture')
|
|
parser.add_argument('--size', type=int, default=256, help='Output resolution (square)')
|
|
parser.add_argument('--supersample', type=int, default=2, help='Internal processing resolution multiplier')
|
|
parser.add_argument('--blur', type=float, default=1.0, help='Gaussian blur radius for height map')
|
|
parser.add_argument('--bump-scale', type=float, default=60.0, help='Normal map bump scale')
|
|
parser.add_argument('--output-color', type=str, default='assets/moon_disk.png', help='Output color filename')
|
|
parser.add_argument('--output-normal', type=str, default='assets/moon_normal.png', help='Output normal filename')
|
|
parser.add_argument('--skip-download', action='store_true', help='Skip downloading files')
|
|
|
|
args = parser.parse_args()
|
|
|
|
internal_size = args.size * args.supersample
|
|
|
|
# Ensure assets dir exists
|
|
os.makedirs(os.path.dirname(args.output_color) or '.', exist_ok=True)
|
|
|
|
# 1. Download
|
|
if not args.skip_download:
|
|
download_file(COLOR_URL, SRC_COLOR_FILENAME)
|
|
download_file(DISP_URL, SRC_DISP_FILENAME)
|
|
|
|
# 2. Process Color
|
|
print(f"Processing Color Map (Internal Size: {internal_size}x{internal_size})...")
|
|
try:
|
|
img_color = Image.open(SRC_COLOR_FILENAME).convert('RGB')
|
|
out_color = equirectangular_to_orthographic(img_color, internal_size)
|
|
|
|
if args.supersample > 1:
|
|
print(f"Downsampling Color to {args.size}x{args.size}...")
|
|
out_color = out_color.resize((args.size, args.size), Image.LANCZOS)
|
|
|
|
out_color.save(args.output_color)
|
|
print(f"Saved {args.output_color}")
|
|
except Exception as e:
|
|
print(f"Error processing color: {e}")
|
|
|
|
# 3. Process Normal
|
|
print(f"Processing Displacement Map (Internal Size: {internal_size}x{internal_size})...")
|
|
try:
|
|
from PIL import ImageFile
|
|
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|
|
|
# Check if file exists
|
|
if not os.path.exists(SRC_DISP_FILENAME):
|
|
print(f"Displacement map {SRC_DISP_FILENAME} not found!")
|
|
return
|
|
|
|
img_disp = Image.open(SRC_DISP_FILENAME)
|
|
out_disp = equirectangular_to_orthographic(img_disp, internal_size)
|
|
|
|
# Compute Normals
|
|
out_normal = compute_normal_map(out_disp, scale=args.bump_scale, blur_radius=args.blur)
|
|
|
|
if args.supersample > 1:
|
|
print(f"Downsampling Normal to {args.size}x{args.size}...")
|
|
out_normal = out_normal.resize((args.size, args.size), Image.LANCZOS)
|
|
|
|
out_normal.save(args.output_normal)
|
|
print(f"Saved {args.output_normal}")
|
|
|
|
except Exception as e:
|
|
print(f"Error processing normal: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
print("Done.")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|