yum-archive/2ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum-archive/2ner
8116b89
master
1#!/usr/bin/env python3 2 3import os 4import argparse 5import time 6import numpy as np 7from multiprocessing import Pool ,cpu_count 8# pip3 install imageio[freeimage] 9import imageio .plugins .freeimage 10import imageio .v2 as imageio 11from scipy .special import sph_harm_y 12 13 14def blur_hdr_spherical_harmonic (hdr_path :str ,blur_radius_deg :float ,output_path :str = None ): 15""" 16Applies an isotropic Gaussian blur to an HDR environment map using spherical harmonics. 1718 Args: 19hdr_path: Path to the input HDR file 20blur_radius_deg: Blur radius in degrees 21output_path: Optional output path 22""" 23total_start = time .time () 24 25if not os .path .exists (hdr_path ): 26raise FileNotFoundError (f"File not found: { hdr_path } " ) 27 28# Load HDR 29"Loading HDR image..." ) 30start = time .time () 31equirect_img = load_hdr (hdr_path ) 32f" Loading took: { time . time () - start :.2f } s" ) 33 34h ,w ,_ = equirect_img .shape 35f" Image size: { w } x { h } " ) 36f" Value range: min= { equirect_img . min ():.3f } , max= { equirect_img . max ():.3f } " ) 37 38# Convert blur radius to radians 39sigma_rad = np .deg2rad (blur_radius_deg ) 40 41# Determine SH bandwidth based on blur radius 42# Rule of thumb: l_max ≈ 3/sigma for good frequency capture 43if sigma_rad > 0 : 44l_max = max (1 ,int (np .ceil (3.0 / sigma_rad ))) 45# Cap at reasonable value to avoid excessive computation 46l_max = min (l_max ,50 ) 47else : 48l_max = 50 # No blur, use higher bandwidth 49 50f"\nUsing spherical harmonic bandwidth: L_max = { l_max } " ) 51f"Total coefficients per channel: { ( l_max + 1 ) ** 2 } " ) 52 53# Project to spherical harmonics 54"\nProjecting to spherical harmonics..." ) 55start = time .time () 56sh_coeffs = project_to_sh (equirect_img ,l_max ) 57f" Projection took: { time . time () - start :.2f } s" ) 58 59# Apply Gaussian filter in SH domain 60if sigma_rad > 0 : 61f"\nApplying Gaussian blur (σ = { blur_radius_deg } °)..." ) 62start = time .time () 63sh_coeffs = apply_sh_gaussian_filter (sh_coeffs ,sigma_rad ) 64f" Filtering took: { time . time () - start :.2f } s" ) 65else : 66"\nSkipping blur (radius = 0)" ) 67 68# Reconstruct from spherical harmonics 69"\nReconstructing from spherical harmonics..." ) 70start = time .time () 71blurred_img = reconstruct_from_sh (sh_coeffs ,w ,h ) 72f" Reconstruction took: { time . time () - start :.2f } s" ) 73 74f" Output value range: min= { blurred_img . min ():.3f } , max= { blurred_img . max ():.3f } " ) 75 76# Save output 77if output_path is None : 78base_name = os .path .splitext (hdr_path )[0 ] 79output_path = f" { base_name } _blurred_ { int ( blur_radius_deg ) } deg.hdr" 80 81f"\nSaving to: { output_path } " ) 82start = time .time () 83save_hdr (output_path ,blurred_img ) 84f" Saving took: { time . time () - start :.2f } s" ) 85 86f"\nTotal time: { time . time () - total_start :.2f } s" ) 87"Done." ) 88 89 90def load_hdr (path ): 91"""Load HDR image with proper float support.""" 92try : 93# Try FreeImage plugin first 94from imageio .plugins import freeimage 95img = freeimage .read (path ) 96except : 97try : 98# Try standard imageio 99img = imageio .imread (path ,format = 'HDR' ) 100except : 101img = imageio .imread (path ) 102 103# Ensure float32 104if img .dtype != np .float32 : 105img = img .astype (np .float32 ) 106if img .max ()> 1.0 : 107img /= 255.0 108 109return img 110 111 112def save_hdr (path ,img ): 113"""Save HDR image.""" 114img = np .clip (img ,0 ,None ).astype (np .float32 ) 115 116try : 117if path .lower ().endswith ('.hdr' ): 118imageio .imwrite (path ,img ,format = 'HDR' ) 119else : 120imageio .imwrite (path ,img ) 121except Exception as e : 122# Fallback 123imageio .imwrite (path ,img ) 124 125 126def get_sh_index (l ,m ): 127"""Convert (l,m) to linear index for SH coefficient storage.""" 128return l * (l + 1 )+ m 129 130 131def eval_sh (l ,m ,theta ,phi ): 132""" 133Evaluate real spherical harmonic Y_lm(theta, phi). 134theta: azimuth [0, 2π] 135phi: inclination from north pole [0, π] 136""" 137# scipy uses physics convention: sph_harm_y(l, m, polar, azimuth) 138# where polar is angle from z-axis 139if m > 0 : 140return np .sqrt (2 )* np .real (sph_harm_y (l ,m ,phi ,theta )) 141elif m < 0 : 142return np .sqrt (2 )* np .imag (sph_harm_y (l ,- m ,phi ,theta )) 143else : 144return np .real (sph_harm_y (l ,0 ,phi ,theta )) 145 146 147def compute_sh_basis_vectorized (height ,width ,l_max ): 148""" 149Pre-compute all spherical harmonic basis functions for all pixels. 150Returns basis_functions[coeff_idx] = Y_lm for all pixels. 151""" 152n_coeffs = (l_max + 1 )** 2 153 154# Create coordinate grids 155y_coords ,x_coords = np .mgrid [0 :height ,0 :width ] 156 157# Convert to spherical coordinates (using pixel centers) 158phi = np .pi * (y_coords + 0.5 )/ height # inclination [0, π] 159theta = 2 * np .pi * (x_coords + 0.5 )/ width - np .pi # azimuth [-π, π] 160 161# Pre-allocate basis functions array 162basis_functions = np .zeros ((n_coeffs ,height ,width ),dtype = np .float32 ) 163 164f" Computing { n_coeffs } basis functions for { height } x { width } pixels..." ) 165 166# Use multiprocessing to compute basis functions in parallel 167n_workers = min (cpu_count (),n_coeffs ) 168 169if n_workers > 1 and n_coeffs > 4 :# Only use multiprocessing for larger problems 170f" Using { n_workers } CPU cores..." ) 171 172# Prepare work chunks 173work_items = [] 174for l in range (l_max + 1 ): 175for m in range (- l ,l + 1 ): 176coeff_idx = get_sh_index (l ,m ) 177work_items .append ((coeff_idx ,l ,m ,theta ,phi )) 178 179# Process in parallel 180with Pool (n_workers )as pool : 181results = pool .map (compute_single_basis ,work_items ) 182 183# Collect results 184for coeff_idx ,basis in results : 185basis_functions [coeff_idx ]= basis 186else : 187# Single-threaded fallback 188for l in range (l_max + 1 ): 189for m in range (- l ,l + 1 ): 190coeff_idx = get_sh_index (l ,m ) 191 192if m > 0 : 193basis_functions [coeff_idx ]= np .sqrt (2 )* np .real (sph_harm_y (l ,m ,phi ,theta )) 194elif m < 0 : 195basis_functions [coeff_idx ]= np .sqrt (2 )* np .imag (sph_harm_y (l ,- m ,phi ,theta )) 196else : 197basis_functions [coeff_idx ]= np .real (sph_harm_y (l ,0 ,phi ,theta )) 198 199return basis_functions 200 201 202def compute_single_basis (work_item ): 203"""Helper function for parallel basis computation.""" 204coeff_idx ,l ,m ,theta ,phi = work_item 205 206if m > 0 : 207basis = np .sqrt (2 )* np .real (sph_harm_y (l ,m ,phi ,theta )) 208elif m < 0 : 209basis = np .sqrt (2 )* np .imag (sph_harm_y (l ,- m ,phi ,theta )) 210else : 211basis = np .real (sph_harm_y (l ,0 ,phi ,theta )) 212 213return coeff_idx ,basis .astype (np .float32 ) 214 215 216def project_to_sh (img ,l_max ): 217"""Project equirectangular image to spherical harmonic coefficients.""" 218h ,w ,channels = img .shape 219n_coeffs = (l_max + 1 )** 2 220 221f" Pre-computing SH basis functions..." ) 222start = time .time () 223 224# Pre-compute all basis functions for all pixels 225basis_functions = compute_sh_basis_vectorized (h ,w ,l_max ) 226 227f" Basis computation took: { time . time () - start :.2f } s" ) 228f" Projecting to coefficients..." ) 229start = time .time () 230 231# Compute solid angle weights 232y_indices = np .arange (h ) 233theta_values = np .pi * (y_indices + 0.5 )/ h # polar angle θ 234sin_theta = np .sin (theta_values )# always ≥ 0 235d_omega = (2 * np .pi / w )* (np .pi / h )# Δφ · Δθ 236 237# Broadcast solid-angle per-row to a (h,w) grid 238solid_angles = d_omega * np .outer (sin_theta ,np .ones (w ))# Shape: (h, w) 239 240# Vectorized projection using matrix operations 241coeffs = np .zeros ((n_coeffs ,channels ),dtype = np .float64 ) 242 243# Flatten image and solid angles for easier computation 244img_flat = img .reshape (- 1 ,channels )# (h*w, channels) 245solid_flat = solid_angles .flatten ()# (h*w,) 246 247# Apply solid angle weighting to image 248weighted_img = img_flat * solid_flat [:,np .newaxis ]# (h*w, channels) 249 250# Flatten all basis functions for matrix multiplication 251basis_flat = basis_functions .reshape (n_coeffs ,- 1 )# (n_coeffs, h*w) 252 253# Matrix multiplication: coeffs = basis_flat @ weighted_img 254coeffs = basis_flat @weighted_img # (n_coeffs, channels) 255 256f" Projection took: { time . time () - start :.2f } s" ) 257return coeffs 258 259 260def apply_sh_gaussian_filter (coeffs ,sigma_rad ): 261"""Apply Gaussian filter in spherical harmonic domain.""" 262n_coeffs = coeffs .shape [0 ] 263l_max = int (np .sqrt (n_coeffs ))- 1 264 265filtered_coeffs = coeffs .copy () 266 267for l in range (l_max + 1 ): 268# Gaussian filter transfer function 269k = np .exp (- 0.5 * l * (l + 1 )* sigma_rad * sigma_rad ) 270 271for m in range (- l ,l + 1 ): 272idx = get_sh_index (l ,m ) 273filtered_coeffs [idx ]*= k 274 275return filtered_coeffs 276 277 278def reconstruct_from_sh (coeffs ,width ,height ): 279"""Reconstruct equirectangular image from spherical harmonic coefficients.""" 280n_coeffs ,channels = coeffs .shape 281l_max = int (np .sqrt (n_coeffs ))- 1 282 283f" Pre-computing SH basis functions..." ) 284start = time .time () 285 286# Pre-compute all basis functions 287basis_functions = compute_sh_basis_vectorized (height ,width ,l_max ) 288 289f" Basis computation took: { time . time () - start :.2f } s" ) 290f" Reconstructing image..." ) 291start = time .time () 292 293# Vectorized reconstruction using matrix operations 294img = np .zeros ((height ,width ,channels ),dtype = np .float32 ) 295 296# Reshape basis functions for matrix multiplication 297basis_flat = basis_functions .reshape (n_coeffs ,- 1 )# (n_coeffs, h*w) 298 299# Matrix multiplication: img_flat = coeffs.T @ basis_flat 300img_flat = coeffs .T @basis_flat # (channels, h*w) 301 302# Reshape back to image format 303img = img_flat .T .reshape (height ,width ,channels )# (h, w, channels) 304 305f" Reconstruction took: { time . time () - start :.2f } s" ) 306return img 307 308 309def main (): 310parser = argparse .ArgumentParser ( 311description = "Apply mathematically correct Gaussian blur to HDR panoramas using spherical harmonics" , 312formatter_class = argparse .ArgumentDefaultsHelpFormatter 313 ) 314 315parser .add_argument ( 316"input" , 317help = "Path to input HDR file" 318 ) 319 320parser .add_argument ( 321"-r" ,"--radius" , 322type = float , 323default = 10.0 , 324help = "Blur radius in degrees" 325 ) 326 327parser .add_argument ( 328"-o" ,"--output" , 329help = "Output file path (default: input_blurred_{radius}deg.hdr)" 330 ) 331 332args = parser .parse_args () 333 334try : 335blur_hdr_spherical_harmonic (args .input ,args .radius ,args .output ) 336except Exception as e : 337f"Error: { e } " ) 338import traceback 339traceback .print_exc () 340return 1 341 342return 0 343 344 345if __name__ == '__main__' : 346# Ensure multiprocessing works on Windows 347from multiprocessing import freeze_support 348freeze_support () 349exit (main ())