yum-archive/2ner

A toon shader for Unity's BIRP.

git clone https://git.yummers.dev/yum-archive/2ner

yumadd fancy cubemap blurring script8116b89

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