├── .gitignore ├── README.md ├── __pycache__ ├── sh_utility.cpython-310.pyc ├── sh_utility.cpython-313.pyc ├── sphericalHarmonics.cpython-310.pyc ├── spherical_harmonics.cpython-310.pyc ├── spherical_harmonics.cpython-313.pyc ├── utility.cpython-310.pyc └── utility.cpython-313.pyc ├── images └── grace-new.exr ├── main.py ├── requirements.txt ├── sh_utilities.py ├── spherical_harmonics.py └── utilities.py /.gitignore: -------------------------------------------------------------------------------- 1 | /output/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spherical Harmonics 2 | Spherical harmonics for radiance maps in Python (numpy). 3 | 4 | Features: 5 | - Obtain coefficients for a radiance map (in equirectangular format) 6 | - Numpy vectorised for efficiency 7 | - Windowing function for reducing ringing artefacts 8 | - Reconstruct radiance map from coefficients 9 | - Obtain diffuse BRDF coefficients 10 | - Render a diffuse map (given radiance map coefficients) 11 | - Supports an arbitrary number of bands 12 | - Plot the spherical harmonics in a figure 13 | - Render a ground truth diffuse map to compare with. 14 | 15 | The ground truth diffuse map can be a little slow to compute, so I've added the ability to render the diffuse values at a low resolution while sampling the high resolution source image. After rendering at a low resolution, I increase the resolution (so it's easier to see) using Lanczos interpolation. I found doing it this way was the most efficient while also producing high quality ground truth images. 16 | 17 | ## Usage 18 | 19 | `python main.py --ibl_filename --l_max --output_dir --resize_width ` 20 | 21 | For example: 22 | 23 | `python main.py --ibl_filename ./images/grace-new.exr --l_max 2` 24 | 25 | See the main function to see examples of functions you can utilise in your own code. 26 | 27 | ## Dependencies 28 | 29 | You can use pip to install the modules I've used, or use the requirements file: 30 | 31 | `pip install -r requirements.txt` 32 | 33 | The only gotcha is with imageio. By default it does not provide OpenEXR support. 34 | To add OpenEXR support to imageio, see here: 35 | https://imageio.readthedocs.io/en/stable/_autosummary/imageio.plugins.freeimage.html#module-imageio.plugins.freeimage 36 | 37 | e.g., run the following in python (after installing imageio): 38 | 39 | `imageio.plugins.freeimage.download()` 40 | 41 | ## References 42 | 43 | - Ramamoorthi, Ravi, and Pat Hanrahan. "An efficient representation for irradiance environment maps", 2001. 44 | - Sloan, Peter-Pike, Jan Kautz, and John Snyder. "Precomputed radiance transfer for real-time rendering in dynamic, low-frequency lighting environments", 2002. 45 | - "Spherical Harmonic Lighting: The Gritty Details" by Robin Green 46 | - "Stupid Spherical Harmonics (SH) Tricks" by Peter Pike Sloan 47 | - PBRT source code: https://www.csie.ntu.edu.tw/~cyy/courses/rendering/pbrt-2.00/html/sh_8cpp_source.html 48 | - Probulator source code (I based my windowing code on this): https://github.com/kayru/Probulator 49 | - Some radiance maps can be downloaded here: http://gl.ict.usc.edu/Data/HighResProbes/ 50 | 51 | ## Future Development 52 | 53 | Depending on demand, the following may be addedd in future: 54 | - Support other formats (e.g. cubemap, angular map, etc.) 55 | - Change, remove, or support other modules (e.g. I use imageio for reading HDR images, cv2 for resizing, etc.) 56 | - More optimisations 57 | - Other windowing methods 58 | - Other visualisations 59 | - Restructure code 60 | 61 | ## License 62 | 63 | MIT License 64 | 65 | Copyright (c) 2018 Andrew Chalmers 66 | 67 | Permission is hereby granted, free of charge, to any person obtaining a copy 68 | of this software and associated documentation files (the "Software"), to deal 69 | in the Software without restriction, including without limitation the rights 70 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 71 | copies of the Software, and to permit persons to whom the Software is 72 | furnished to do so, subject to the following conditions: 73 | 74 | The above copyright notice and this permission notice shall be included in all 75 | copies or substantial portions of the Software. 76 | 77 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 78 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 79 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 80 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 81 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 82 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 83 | SOFTWARE. 84 | -------------------------------------------------------------------------------- /__pycache__/sh_utility.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/sh_utility.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/sh_utility.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/sh_utility.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/sphericalHarmonics.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/sphericalHarmonics.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/spherical_harmonics.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/spherical_harmonics.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/spherical_harmonics.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/spherical_harmonics.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/utility.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/utility.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/utility.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/__pycache__/utility.cpython-313.pyc -------------------------------------------------------------------------------- /images/grace-new.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chalmersgit/SphericalHarmonics/5c053617bf63f11d4f5dc37e10d273036793dfdf/images/grace-new.exr -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | ''' 2 | MIT License 3 | 4 | Copyright (c) 2018 Andrew Chalmers 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | ''' 24 | 25 | ''' 26 | Spherical harmonics for radiance maps using numpy. 27 | 28 | Assumes: 29 | Equirectangular format. 30 | theta: [0 to pi], from top to bottom row of pixels. 31 | phi: [0 to 2*Pi], from left to right column of pixels. 32 | ''' 33 | 34 | import os 35 | import sys 36 | import numpy as np 37 | import argparse 38 | import imageio.v3 as im 39 | import cv2 # For resizing images with float support 40 | 41 | # Custom 42 | import sh_utilities 43 | import utilities 44 | 45 | def google_example_data(direction, width): 46 | """ 47 | Example function for cosine lobe calculation. For further details, see Google's implementation at https://github.com/google/spherical-harmonics/blob/master/sh/spherical_harmonics.cc 48 | const std::vector google = { 0.886227, 0.0, 1.02333, 0.0, 0.0, 0.0, 0.495416, 0.0, 0.0 }; 49 | 50 | Args: 51 | direction (ndarray): Direction vector. 52 | width (int): Width of the image. 53 | 54 | Returns: 55 | ndarray: radiance map with pixel values following a cosine lobe 56 | 57 | Reference: 58 | 59 | """ 60 | xyz = sh_utilities.get_cartesian_map(width) 61 | return utilities.grey_to_colour(np.clip(np.sum(direction * xyz, axis=2), 0.0, 1.0)) 62 | 63 | def gritty_details_example_data(width): 64 | """ 65 | Example function based on "The Gritty Details" by Robin Green, page 17 (note, the paper is missing the first coefficient in the fourth band) 66 | 67 | Args: 68 | width (int): Width of the image. 69 | 70 | Returns: 71 | ndarray: radiance map with pixel values following gritty details example 72 | """ 73 | 74 | x = np.arange(0,width) 75 | y = np.arange(0,width//2).reshape(width//2,1) 76 | lat_lon = sh_utilities.xy_to_ll(x,y,width,width//2) 77 | theta = np.repeat(lat_lon[0][:, np.newaxis], width, axis=1).reshape((width//2, width)) 78 | phi = np.repeat(lat_lon[1][np.newaxis, :], width//2, axis=0).reshape((width//2, width)) 79 | return utilities.grey_to_colour(np.maximum(0.0, 5 * np.cos(theta) - 4) + 80 | np.maximum(0.0, -4 * np.sin(theta - np.pi) * np.cos(phi - 2.5) - 3)) 81 | 82 | def run_gritty_details_example(): 83 | """ 84 | Run the gritty details example and print the SH coefficients. 85 | """ 86 | l_max = 3 87 | width = 2048 88 | radiance_map_data = gritty_details_example_data(width) 89 | 90 | ibl_coeffs = sh_utilities.get_coefficients_from_image(radiance_map_data, l_max, resize_width=width) 91 | print("Google's example result:") 92 | sh_utilities.sh_print(ibl_coeffs) 93 | 94 | def run_google_example(): 95 | """ 96 | Run the Google cosine lobe example and print the SH coefficients. 97 | """ 98 | l_max = 2 99 | width = 2048 100 | radiance_map_data = google_example_data([0,1,0], width) 101 | 102 | ibl_coeffs = sh_utilities.get_coefficients_from_image(radiance_map_data, l_max, resize_width=width) 103 | print("Google's example result:") 104 | sh_utilities.sh_print(ibl_coeffs) 105 | 106 | 107 | def parse_arguments(): 108 | """Parse command-line arguments.""" 109 | parser = argparse.ArgumentParser(description="Process IBL filename and number of bands.") 110 | parser.add_argument('--ibl_filename', type=str, default='./images/grace-new.exr', 111 | help="Path to the IBL filename (default: './images/grace-new.exr').") 112 | parser.add_argument('--l_max', type=int, default=3, 113 | help="Number of bands (must be an integer, default: 3).") 114 | parser.add_argument('--output_dir', type=str, default='./output/', 115 | help="Output directory (default: './output/').") 116 | parser.add_argument('--resize_width', type=int, default=1000, 117 | help="Width to resize the image (default: 1000).") 118 | return parser.parse_args() 119 | 120 | 121 | def main(): 122 | # Parsing input 123 | args = parse_arguments() 124 | 125 | print("Spherical Harmonics for latitude-longitude radiance maps") 126 | print(f"Input File (IBL): {args.ibl_filename}") 127 | print(f"Number of Bands (l_max): {args.l_max}") 128 | print(f"Resize Width: {args.resize_width}") 129 | print(f"Output Directory: {args.output_dir}\n") 130 | 131 | if not os.path.exists(args.output_dir): 132 | os.makedirs(args.output_dir) 133 | 134 | resize_height = args.resize_width // 2 135 | 136 | # Visualize the spherical harmonic functions 137 | print("Plotting spherical harmonic functions...") 138 | sh_utilities.sh_visualise(args.l_max, show=False, output_dir=args.output_dir) 139 | 140 | # Read image 141 | print("Reading image...") 142 | radiance_map_data = utilities.resize_image( 143 | im.imread(args.ibl_filename, plugin='EXR-FI')[:, :, :3], 144 | args.resize_width, 145 | resize_height, 146 | cv2.INTER_CUBIC 147 | ) 148 | 149 | im.imwrite(os.path.join(args.output_dir, '_radiance_map_data.exr'), radiance_map_data.astype(np.float32)) 150 | im.imwrite(os.path.join(args.output_dir, '_radiance_map_data.jpg'), utilities.linear2sRGB(radiance_map_data)) 151 | 152 | # SPH projection 153 | print("Running spherical harmonics...") 154 | #ibl_coeffs = sh_utilities.get_coefficients_from_file(args.ibl_filename, args.l_max, resize_width=args.resize_width) 155 | ibl_coeffs = sh_utilities.get_coefficients_from_image(radiance_map_data, args.l_max, resize_width=args.resize_width) 156 | sh_utilities.write_reconstruction(ibl_coeffs, args.l_max, '_SPH', width=args.resize_width, output_dir=args.output_dir) 157 | #sh_utilities.sh_print(ibl_coeffs) 158 | sh_utilities.sh_print_to_file(ibl_coeffs) 159 | 160 | print("Spherical harmonics processing is complete.\n") 161 | 162 | # Generate ground truth diffuse map for comparison 163 | print("Generating ground truth diffuse map for comparison...") 164 | diffuse_low_res_width = 32 # Trade-off between processing time and ground truth quality 165 | output_width = args.resize_width 166 | diffuse_ibl_gt = utilities.get_roughness_map( 167 | args.ibl_filename, 168 | width=args.resize_width, 169 | width_low_res=diffuse_low_res_width, 170 | output_width=output_width 171 | ) 172 | im.imwrite(os.path.join(args.output_dir, f'_diffuse_ibl_gt_{args.resize_width}_{diffuse_low_res_width}_{output_width}.exr'), diffuse_ibl_gt.astype(np.float32)) 173 | im.imwrite(os.path.join(args.output_dir, f'_diffuse_ibl_gt_{args.resize_width}_{diffuse_low_res_width}_{output_width}.jpg'), utilities.linear2sRGB(diffuse_ibl_gt)) 174 | 175 | print("Complete.") 176 | 177 | 178 | if __name__ == "__main__": 179 | main() 180 | #run_google_example() 181 | #run_gritty_details_example() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | imageio==2.36.0 2 | matplotlib==3.9.2 3 | numpy==2.1.2 4 | opencv_contrib_python==4.10.0.84 5 | opencv_python==4.10.0.82 6 | scipy==1.14.1 7 | scikit-image==0.25.0 -------------------------------------------------------------------------------- /sh_utilities.py: -------------------------------------------------------------------------------- 1 | ''' 2 | MIT License 3 | 4 | Copyright (c) 2018 Andrew Chalmers 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | ''' 24 | 25 | ''' 26 | Utility functions to demonstrate usage of spherical harmonics 27 | ''' 28 | 29 | import os 30 | import numpy as np 31 | import matplotlib.pyplot as plt 32 | from matplotlib.colors import LinearSegmentedColormap 33 | import imageio.v3 as im 34 | import cv2 # resize images with float support 35 | import math 36 | 37 | # Custom 38 | import spherical_harmonics as sh 39 | import utility 40 | 41 | def get_coefficients_matrix(xres,l_max=2): 42 | yres = int(xres/2) 43 | # setup fast vectorisation 44 | x = np.arange(0,xres) 45 | y = np.arange(0,yres).reshape(yres,1) 46 | 47 | # Setup polar coordinates 48 | lat_lon = xy_to_ll(x,y,xres,yres) 49 | 50 | # Compute spherical harmonics. Apply thetaOffset due to EXR spherical coordiantes 51 | Ylm = sh.sh_evaluate(lat_lon[0], lat_lon[1], l_max) 52 | return Ylm 53 | 54 | def get_coefficients_from_file(ibl_filename, l_max=2, resize_width=None, filder_amount=None): 55 | ibl = im.imread(os.path.join(os.path.dirname(__file__), ibl_filename), plugin='EXR-FI') 56 | return get_coefficients_from_image(ibl, l_max=l_max, resize_width=resize_width, filder_amount=filder_amount) 57 | 58 | def get_coefficients_from_image(ibl, l_max=2, resize_width=None, filder_amount=None): 59 | # Resize if necessary (I recommend it for large images) 60 | if resize_width is not None: 61 | #ibl = cv2.resize(ibl, dsize=(resize_width,int(resize_width/2)), interpolation=cv2.INTER_CUBIC) 62 | ibl = utility.resize_image(ibl, resize_width, int(resize_width/2), cv2.INTER_CUBIC) 63 | elif ibl.shape[1] > 1000: 64 | #print("Input resolution is large, reducing for efficiency") 65 | #ibl = cv2.resize(ibl, dsize=(1000,500), interpolation=cv2.INTER_CUBIC) 66 | ibl = utility.resize_image(ibl, 1000, 500, cv2.INTER_CUBIC) 67 | xres = ibl.shape[1] 68 | yres = ibl.shape[0] 69 | 70 | # Pre-filtering, windowing 71 | if filder_amount is not None: 72 | ibl = blur_ibl(ibl, amount=filder_amount) 73 | 74 | # Compute sh coefficients 75 | sh_basis_matrix = get_coefficients_matrix(xres,l_max) 76 | 77 | # Sampling weights 78 | solid_angles = utility.get_solid_angle_map(xres) 79 | 80 | # Project IBL into SH basis 81 | n_coeffs = sh.sh_terms(l_max) 82 | ibl_coeffs = np.zeros((n_coeffs,3)) 83 | for i in range(0,sh.sh_terms(l_max)): 84 | ibl_coeffs[i,0] = np.sum(ibl[:,:,0]*sh_basis_matrix[:,:,i]*solid_angles) 85 | ibl_coeffs[i,1] = np.sum(ibl[:,:,1]*sh_basis_matrix[:,:,i]*solid_angles) 86 | ibl_coeffs[i,2] = np.sum(ibl[:,:,2]*sh_basis_matrix[:,:,i]*solid_angles) 87 | 88 | return ibl_coeffs 89 | 90 | def find_windowing_factor(coeffs, max_laplacian=10.0): 91 | # http://www.ppsloan.org/publications/StupidSH36.pdf 92 | # Based on probulator implementation, empirically chosen max_laplacian 93 | l_max = sh_l_max_from_terms(coeffs.shape[0]) 94 | tableL = np.zeros((l_max+1)) 95 | tableB = np.zeros((l_max+1)) 96 | 97 | def sqr(x): 98 | return x*x 99 | def cube(x): 100 | return x*x*x 101 | 102 | for l in range(1, l_max+1): 103 | tableL[l] = float(sqr(l) * sqr(l + 1)) 104 | B = 0.0 105 | for m in range(-1, l+1): 106 | B += np.mean(coeffs[sh_index(l,m),:]) 107 | tableB[l] = B; 108 | 109 | squared_laplacian = 0.0 110 | for l in range(1, l_max+1): 111 | squared_laplacian += tableL[l] * tableB[l] 112 | 113 | target_squared_laplacian = max_laplacian * max_laplacian; 114 | if (squared_laplacian <= target_squared_laplacian): return 0.0 115 | 116 | windowing_factor = 0.0 117 | iterationLimit = 10000000; 118 | for i in range(0, iterationLimit): 119 | f = 0.0 120 | fd = 0.0 121 | for l in range(1, l_max+1): 122 | f += tableL[l] * tableB[l] / sqr(1.0 + windowing_factor * tableL[l]); 123 | fd += (2.0 * sqr(tableL[l]) * tableB[l]) / cube(1.0 + windowing_factor * tableL[l]); 124 | 125 | f = target_squared_laplacian - f; 126 | 127 | delta = -f / fd; 128 | windowing_factor += delta; 129 | if (abs(delta) < 0.0000001): 130 | break 131 | return windowing_factor 132 | 133 | def apply_windowing(coeffs, windowing_factor=None, verbose=False): 134 | # http://www.ppsloan.org/publications/StupidSH36.pdf 135 | l_max = sh_l_max_from_terms(coeffs.shape[0]) 136 | if windowing_factor is None: 137 | windowing_factor = find_windowing_factor(coeffs) 138 | if windowing_factor <= 0: 139 | if verbose: print("No windowing applied") 140 | return coeffs 141 | if verbose: print("Using windowing_factor: %s" % (windowing_factor)) 142 | for l in range(0, l_max+1): 143 | s = 1.0 / (1.0 + windowing_factor * l * l * (l + 1.0) * (l + 1.0)) 144 | for m in range(-l, l+1): 145 | coeffs[sh_index(l,m),:] *= s; 146 | return coeffs 147 | 148 | # Spherical harmonics reconstruction 149 | def get_diffuse_coefficients(l_max): 150 | # From "An Efficient Representation for Irradiance Environment Maps" (2001), Ramamoorthi & Hanrahan 151 | diffuse_coeffs = [np.pi, (2*np.pi)/3] 152 | for l in range(2,l_max+1): 153 | if l%2==0: 154 | a = (-1.)**((l/2.)-1.) 155 | b = (l+2.)*(l-1.) 156 | #c = float(np.math.factorial(l)) / (2**l * np.math.factorial(l/2)**2) 157 | c = math.factorial(int(l)) / (2**l * math.factorial(int(l//2))**2) 158 | #s = ((2*l+1)/(4*np.pi))**0.5 159 | diffuse_coeffs.append(2*np.pi*(a/b)*c) 160 | else: 161 | diffuse_coeffs.append(0) 162 | return np.asarray(diffuse_coeffs) / np.pi 163 | 164 | def sh_reconstruct_signal(coeffs, sh_basis_matrix=None, width=600): 165 | if sh_basis_matrix is None: 166 | l_max = sh_l_max_from_terms(coeffs.shape[0]) 167 | sh_basis_matrix = get_coefficients_matrix(width,l_max) 168 | return np.dot(sh_basis_matrix,coeffs).astype(np.float32) 169 | 170 | def sh_render(ibl_coeffs, width=600): 171 | l_max = sh_l_max_from_terms(ibl_coeffs.shape[0]) 172 | diffuse_coeffs =get_diffuse_coefficients(l_max) 173 | sh_basis_matrix = get_coefficients_matrix(width,l_max) 174 | rendered_image = np.zeros((int(width/2),width,3)) 175 | for idx in range(0,ibl_coeffs.shape[0]): 176 | l = l_from_idx(idx) 177 | coeff_rgb = diffuse_coeffs[l] * ibl_coeffs[idx,:] 178 | rendered_image[:,:,0] += sh_basis_matrix[:,:,idx] * coeff_rgb[0] 179 | rendered_image[:,:,1] += sh_basis_matrix[:,:,idx] * coeff_rgb[1] 180 | rendered_image[:,:,2] += sh_basis_matrix[:,:,idx] * coeff_rgb[2] 181 | return rendered_image 182 | 183 | def get_normal_map_axes_duplicate_rgb(normal_map): 184 | # Make normal for each axis, but in 3D so we can multiply against RGB 185 | N3Dx = np.repeat(normal_map[:,:,0][:, :, np.newaxis], 3, axis=2) 186 | N3Dy = np.repeat(normal_map[:,:,1][:, :, np.newaxis], 3, axis=2) 187 | N3Dz = np.repeat(normal_map[:,:,2][:, :, np.newaxis], 3, axis=2) 188 | return N3Dx, N3Dy, N3Dz 189 | 190 | def sh_render_l2(ibl_coeffs, normal_map): 191 | # From "An Efficient Representation for Irradiance Environment Maps" (2001), Ramamoorthi & Hanrahan 192 | C1 = 0.429043 193 | C2 = 0.511664 194 | C3 = 0.743125 195 | C4 = 0.886227 196 | C5 = 0.247708 197 | N3Dx, N3Dy, N3Dz = get_normal_map_axes_duplicate_rgb(normal_map) 198 | return (C4 * ibl_coeffs[0,:] + \ 199 | 2.0 * C2 * ibl_coeffs[3,:] * N3Dx + \ 200 | 2.0 * C2 * ibl_coeffs[1,:] * N3Dy + \ 201 | 2.0 * C2 * ibl_coeffs[2,:] * N3Dz + \ 202 | C1 * ibl_coeffs[8,:] * (N3Dx * N3Dx - N3Dy * N3Dy) + \ 203 | C3 * ibl_coeffs[6,:] * N3Dz * N3Dz - C5 * ibl_coeffs[6] + \ 204 | 2.0 * C1 * ibl_coeffs[4,:] * N3Dx * N3Dy + \ 205 | 2.0 * C1 * ibl_coeffs[7,:] * N3Dx * N3Dz + \ 206 | 2.0 * C1 * ibl_coeffs[5,:] * N3Dy * N3Dz ) / np.pi 207 | 208 | def get_normal_map(width): 209 | height = int(width/2) 210 | x = np.arange(0,width) 211 | y = np.arange(0,height).reshape(height,1) 212 | lat_lon = xy_to_ll(x,y,width,height) 213 | return spherical_to_cartesian_2(lat_lon[0], lat_lon[1]) 214 | 215 | def sh_reconstruct_diffuse_map(ibl_coeffs, width=600): 216 | # Rendering 217 | if ibl_coeffs.shape[0] == 9: # L2 218 | # setup fast vectorisation 219 | xyz = get_normal_map(width) 220 | rendered_image = sh_render_l2(ibl_coeffs, xyz) 221 | else: # !L2 222 | rendered_image = sh_render(ibl_coeffs, width) 223 | 224 | return rendered_image.astype(np.float32) 225 | 226 | def write_reconstruction(c, l_max, fn='', width=600, output_dir='./output/'): 227 | reconstructed_signal = sh_reconstruct_signal(c, width=width) 228 | reconstructed_diffuse = sh_reconstruct_diffuse_map(c, width=width) 229 | 230 | im.imwrite(output_dir+'_sh_light_l'+str(l_max)+fn+'.exr',reconstructed_signal) 231 | im.imwrite(output_dir+'_sh_render_l'+str(l_max)+fn+'.exr',reconstructed_diffuse) 232 | 233 | im.imwrite(output_dir+'_sh_light_l'+str(l_max)+fn+'.jpg',utility.linear2sRGB(reconstructed_signal)) 234 | im.imwrite(output_dir+'_sh_render_l'+str(l_max)+fn+'.png',utility.linear2sRGB(reconstructed_diffuse)) 235 | 236 | # Utility functions for SPH 237 | def sh_print(coeffs, precision=3): 238 | n_coeffs = coeffs.shape[0] 239 | l_max = sh_l_max_from_terms(coeffs.shape[0]) 240 | currentBand = -1 241 | for idx in range(0,n_coeffs): 242 | band = l_from_idx(idx) 243 | if currentBand!=band: 244 | currentBand = band 245 | print('L'+str(currentBand)+":") 246 | print(np.around(coeffs[idx,:],precision)) 247 | print('') 248 | 249 | def sh_print_to_file(coeffs, precision=3, output_file_path="./output/_coefficients.txt"): 250 | n_coeffs = coeffs.shape[0] 251 | l_max = sh_l_max_from_terms(coeffs.shape[0]) 252 | currentBand = -1 253 | 254 | with open(output_file_path, 'w') as file: 255 | for idx in range(0,n_coeffs): 256 | 257 | # Print the band level 258 | band = l_from_idx(idx) 259 | if currentBand!=band: 260 | currentBand = band 261 | outputData_BandLevel = "L"+str(currentBand)+":" 262 | print(outputData_BandLevel) 263 | file.write(outputData_BandLevel+'\n') 264 | 265 | # Print the coefficients at this band level 266 | outputData_Coefficients = np.around(coeffs[idx,:],precision) 267 | print(outputData_Coefficients) 268 | file.write("[") 269 | for i in range(0,len(outputData_Coefficients)): 270 | file.write(str(outputData_Coefficients[i])) 271 | if i 0: 51 | somx2 = np.sqrt((1.0 - x) * (1.0 + x)) 52 | fact = 1.0 53 | for i in range(1, m + 1): 54 | pmm *= (-fact) * somx2 55 | fact += 2.0 56 | 57 | if l == m: 58 | return pmm * np.ones(x.shape) 59 | 60 | pmmp1 = x * (2.0 * m + 1.0) * pmm 61 | 62 | if l == m + 1: 63 | return pmmp1 64 | 65 | pll = np.zeros(x.shape) 66 | for ll in range(m + 2, l + 1): 67 | pll = ((2.0 * ll - 1.0) * x * pmmp1 - (ll + m - 1.0) * pmm) / (ll - m) 68 | pmm = pmmp1 69 | pmmp1 = pll 70 | 71 | return pll 72 | 73 | 74 | def divfact(a, b): 75 | """ 76 | Computes the factorial ratio (a! / b!). 77 | 78 | Args: 79 | a (int): Numerator value. 80 | b (int): Denominator value. 81 | 82 | Returns: 83 | float: The factorial division result. 84 | """ 85 | if b == 0: 86 | return 1.0 87 | v = 1.0 88 | x = a - b + 1.0 89 | while x <= a + b: 90 | v *= x 91 | x += 1.0 92 | return 1.0 / v 93 | 94 | 95 | def factorial(x): 96 | """ 97 | Computes the factorial of x. 98 | 99 | Args: 100 | x (int): Input integer. 101 | 102 | Returns: 103 | float: Factorial of x. 104 | """ 105 | return 1.0 if x == 0 else x * factorial(x - 1) 106 | 107 | 108 | def K(l, m): 109 | """ 110 | Computes the normalization constant K(l, m). 111 | 112 | Args: 113 | l (int): Band. 114 | m (int): Coefficient within band. 115 | 116 | Returns: 117 | float: Normalization constant. 118 | """ 119 | return np.sqrt(((2 * l + 1) * factorial(l - m)) / (4 * np.pi * factorial(l + m))) 120 | 121 | 122 | def K_fast(l, m): 123 | """ 124 | Computes a fast approximation of the normalization constant K(l, m). 125 | 126 | Args: 127 | l (int): Band. 128 | m (int): Coefficient within band. 129 | 130 | Returns: 131 | float: Approximation of the normalization constant. 132 | """ 133 | cAM = abs(m) 134 | uVal = 1.0 135 | k = l + cAM 136 | while k > (l - cAM): 137 | uVal *= k 138 | k -= 1 139 | return np.sqrt((2.0 * l + 1.0) / (4 * np.pi * uVal)) 140 | 141 | 142 | def sh(l, m, theta, phi): 143 | """ 144 | Computes the spherical harmonics function Y(l, m) for angles theta and phi. 145 | 146 | Args: 147 | l (int): Band. 148 | m (int): Coefficient within band. 149 | theta (ndarray): Azimuth angle. 150 | phi (ndarray): Elevation angle. 151 | 152 | Returns: 153 | ndarray: Spherical harmonics function value. 154 | """ 155 | sqrt2 = np.sqrt(2.0) 156 | cos_theta = np.cos(theta) 157 | if m == 0: 158 | return K(l, m) * P(l, m, cos_theta) 159 | elif m > 0: 160 | return sqrt2 * K(l, m) * np.cos(m * phi) * P(l, m, cos_theta) 161 | else: 162 | return sqrt2 * K(l, -m) * np.sin(-m * phi) * P(l, -m, cos_theta) 163 | 164 | 165 | def sh_terms(l_max): 166 | """ 167 | Computes the total number of spherical harmonics terms for a given maximum bands. 168 | 169 | Args: 170 | l_max (int): Maximum bands. 171 | 172 | Returns: 173 | int: Number of spherical harmonics terms. 174 | """ 175 | return (l_max + 1) * (l_max + 1) 176 | 177 | 178 | def sh_index(l, m): 179 | """ 180 | Computes the index for accessing the (l, m) spherical harmonics term in a 1D array. 181 | 182 | Args: 183 | l (int): Band. 184 | m (int): Coefficient within band. 185 | 186 | Returns: 187 | int: Index of the spherical harmonics term. 188 | """ 189 | return l * l + l + m 190 | 191 | 192 | def sh_evaluate(theta, phi, l_max): 193 | """ 194 | Evaluates the spherical harmonics up to a given maximum bands for input angles. 195 | 196 | Args: 197 | theta (ndarray): Azimuth angles. 198 | phi (ndarray): Elevation angles. 199 | l_max (int): Maximum bands for the spherical harmonics. 200 | 201 | Returns: 202 | ndarray: Spherical harmonics coefficients matrix. 203 | """ 204 | coeffs_matrix = np.zeros((theta.shape[0], phi.shape[0], sh_terms(l_max))) 205 | 206 | for l in range(0, l_max + 1): 207 | for m in range(-l, l + 1): 208 | index = sh_index(l, m) 209 | coeffs_matrix[:, :, index] = sh(l, m, theta, phi) 210 | 211 | return coeffs_matrix 212 | -------------------------------------------------------------------------------- /utilities.py: -------------------------------------------------------------------------------- 1 | ''' 2 | MIT License 3 | 4 | Copyright (c) 2018 Andrew Chalmers 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | ''' 24 | 25 | ''' 26 | Utility functions 27 | ''' 28 | 29 | import os 30 | import numpy as np 31 | import math 32 | import argparse 33 | import imageio.v3 as im 34 | import cv2 # resize images with float support 35 | from scipy import ndimage # gaussian blur 36 | import skimage.measure # max_pooling with block_reduce 37 | import time 38 | 39 | def blur_ibl(ibl, amount=5): 40 | x = ibl.copy() 41 | x[:,:,0] = ndimage.gaussian_filter(ibl[:,:,0], sigma=amount) 42 | x[:,:,1] = ndimage.gaussian_filter(ibl[:,:,1], sigma=amount) 43 | x[:,:,2] = ndimage.gaussian_filter(ibl[:,:,2], sigma=amount) 44 | return x 45 | 46 | def resize_image(img, width, height, interpolation=cv2.INTER_CUBIC): 47 | if img.shape[1] 0.0031308 181 | 182 | # Gamma correction 183 | hdr_img[lower] *= 12.92 184 | hdr_img[upper] = 1.055 * np.power(hdr_img[upper], 1.0/gamma) - 0.055 185 | 186 | # HDR to LDR format 187 | img_8bit = np.clip(hdr_img*255, 0, 255).astype('uint8') 188 | return img_8bit 189 | 190 | 191 | if __name__ == "__main__": 192 | print("Generating roughness maps...") 193 | output_dir = './output/' 194 | ibl_filename = './images/grace-new.exr' 195 | 196 | # Trade-off between processing time and ground truth quality 197 | # Note 1: High resize_width maintains high intensity values in source, while roughness_low_res_width reduces the convolution size 198 | # Note 2: Mismatch between resolutions can cause missing holes (black pixels) in images with low roughness 199 | resize_width = 512 200 | roughness_low_res_width = 32 201 | 202 | for roughness in [0.1, 0.25, 0.5, 0.75, 1.0]: 203 | output_width = resize_width 204 | roughness_ibl_gt = get_roughness_map( 205 | ibl_filename, 206 | width=resize_width, 207 | width_low_res=roughness_low_res_width, 208 | output_width=output_width, 209 | roughness=roughness 210 | ) 211 | im.imwrite(os.path.join(output_dir, f'_roughness_hdr_{roughness}.exr'), roughness_ibl_gt.astype(np.float32)) 212 | im.imwrite(os.path.join(output_dir, f'_roughness_ldr_{roughness}.jpg'), linear2sRGB(roughness_ibl_gt)) 213 | 214 | print("Complete.") 215 | 216 | --------------------------------------------------------------------------------