Skip to content

Real spherical harmonics and radiometry

Import from gem.spherical_harmonics. Legendre is a retained imported class, not a second implementation; its canonical reference is gem.legendre. Source, projection/basis tests, analytical rotation, HDR reference.

Basis and coefficient layout

Degree l≥0 and order −l≤m≤l are integers; theta is polar angle from +Z and phi azimuth from +X toward +Y, both radians. P_l^m includes Condon–Shortley phase. K(l,m)=sqrt((2l+1)(l−m)!/(4pi(l+m)!)). Real orthonormal basis: Y_l0=K(l,0)P_l^0(cos theta); Y_lm=sqrt(2)K(l,m)cos(m phi)P_l^m(cos theta) for m>0; Y_lm=sqrt(2)K(l,−m)sin(−m phi)P_l^(−m)(cos theta) for m<0.

Index=l(l+1)+m; complete numBands bands contain numBands² coefficients. Canonical L2 coordinate polynomials and scales are:

Index (l,m) Basis at unit (X,Y,Z)
0 (0,0) 1/sqrt(4pi)
1 (1,−1) −sqrt(3/(4pi))*Y
2 (1,0) sqrt(3/(4pi))*Z
3 (1,1) −sqrt(3/(4pi))*X
4 (2,−2) sqrt(15/(4pi))*XY
5 (2,−1) −sqrt(15/(4pi))*YZ
6 (2,0) sqrt(5/(16pi))*(3Z²−1)
7 (2,1) −sqrt(15/(4pi))*XZ
8 (2,2) sqrt(15/(16pi))*(X²−Y²)

RGB APIs use coefficient-by-channel lists [[R,G,B],...]. Only analytical rotation also accepts scalar coefficient arrays. Reconstruction is RGB-only. No public module constants or cache-management APIs are exposed; imported math/random/etc and private basis-layout cache are implementation details.

Angle evaluation retains sin(theta) in the associated seed instead of recovering it from rounded cos(theta). The exact polar endpoints 0 and pi keep zero transverse terms. Reconstruction retains a unit direction's hypot(X,Y) and Z, and its first/second-order azimuth factors, without an inverse-trigonometric round trip or implicit normalization. Small nonzero components at either pole survive; ordinary last-bit rounding may differ. The public Legendre API, recurrence and high-order limits are unchanged. See the near-pole repair evidence.

Functions and containers

Exact source declaration Parameters, result and behavior
Factorial(n) n: nonnegative integer prerequisite; scalar n!, iterative. Historical n≤1 returns 1 even outside domain; no broad validation.
K(l, m) Integers l,m, 0≤m≤l; positive scalar normalization K above. No high-order overflow stabilization or uniform invalid-domain errors.
SPH(l, m, theta, phi) Integer l,m, −l≤m≤l, finite numeric theta,phi radians; scalar real Y_lm. Uses unnormalized Legendre and K, preserving phase. No generalized angular/domain validator.
SPHSample Mutable sample container; precomputed values drive projection, not re-evaluation of dir.
SPHSample.__init__(self, theta, phi, dirc, sampleNumber) theta,phi: stored radians; dirc: Vector reference retained, otherwise fresh zero Vector3; sampleNumber: basis-array length (normally bands²), initialize values to zeros; return None.
GenerateSamples(sqrtNumSamples, numBands) Positive integer sqrtNumSamples and numBands in supported use; fresh list of N=sqrtNumSamples² SPHSamples with complete basis values. Global-RNG jittered equal-solid-angle strata, default weight 4pi/N; no progress printing. Invalid arguments retain legacy arithmetic (zero causes ZeroDivisionError).
project_radiance(samples, radiances, weights=None) Nonempty matching samples: SPHSamples and radiances: finite RGB triples. weights=None: 4pi/N, suitable for uniform sphere samples; otherwise matching finite nonnegative solid angles. Return fresh canonical RGB coefficients via weighted basis sums. Complete matching finite values required (ValueError); inputs preserved.
project_angular_probe(hdr, numBands=3) hdr: nonempty rectangular row/column/finite-RGB lists; numBands=3: positive integer, bool rejected. Fresh canonical RGB radiance coefficients from angular-disk pixel-center quadrature; malformed layouts/nonfinite RGB raise ValueError on supported sequence forms. No image decoding.
reconstruct(coefficients, direction) coefficients: finite RGB complete nonempty bands; direction: unit Vector3 or XYZ triple prerequisite. Fresh RGB sum c_i*Y_i; no normalization, clamping or convolution. Checks finite/nonzero triple and Z∈[−1,1], not full unit norm. Invalid checked data raises ValueError; malformed foreign containers may raise native errors.
convolve_diffuse(radiance_coefficients) radiance_coefficients: finite RGB complete bands, 1/4/9 entries only; fresh RGB irradiance coefficients, degree factors pi,2pi/3,pi/4. Invalid checked layouts/nonfinite data or higher bands raise ValueError; no mutation.
legacy_to_canonical(coefficients) Exactly nine finite legacy RGB radiance rows; fresh canonical rows, flipping odd-order signs and correcting rounded scale constants. Invalid checked layout/nonfinite data ValueError. Import migration alone does not convert a basis.
SPH_IrradianceMapCoeff Historical file-backed angular-probe class; .coeffs is legacy radiance despite class name, not convolved irradiance.
SPH_IrradianceMapCoeff.__init__(self, fileU, width, height) fileU: binary file path; width,height: positive ints excluding bool. Store file/dimensions, read native-endian float32 RGB, build hdr and nine legacy radiance rows; returns None. Invalid dimensions/short data ValueError; file errors propagate.
SPH_IrradianceMapCoeff.load(self) Read exactly widthheight3 floats from file (trailing bytes ignored); replace row-major hdr and recompute legacy coeffs; return None. Missing file/OSError and short-data ValueError propagate.
SPH_IrradianceMapCoeff.calculateCoefficients(self) Clear coeffs then accumulate hdr pixel-center quadrature, return None; repeated calls rebuild rather than double accumulation. Uses angular validation; .hdr preserved.
SPH_IrradianceMapCoeff.updateCoefficients(self, hdr, domega, x, y, z) hdr: one RGB triple, domega: weight, x,y,z: supplied direction coordinates. Accumulate nine rounded legacy polynomial radiance terms into existing coeffs, return None. No normalization/convolution or new validation; input triple preserved.
SPH_IrradianceMapCoeff.output(self) Pretty-print current coeffs to stdout; return None, no coefficient mutation.
rotate_coefficients(coefficients, orientation) Canonical finite scalar arrays or RGB rows of length 1/4/9; orientation: finite Quaternion with norm drift ≤1e−12. Normalize temporary data, return independent active analytic rotation. Reject invalid orientation/layout with ValueError; no Matrix adapter, resampling or implicit legacy conversion.

Sampling, projection and reconstruction

SPHSample.theta, .phi, .dir and .values are public mutable state. Its constructor retains a supplied Vector; GenerateSamples creates separate Vectors. Set the global random seed for repeatability (this changes application RNG state): u=(i+random())/sqrtNumSamples, v=(j+random())/sqrtNumSamples, theta=2acos(sqrt(1−u)), phi=2pi*v. Projection uses stored values and caller RGB samples, so keeping dir/angles/values consistent is the caller's responsibility. Explicit weights are steradians and are not forced to sum to 4pi.

import math
import random
from gem.spherical_harmonics import GenerateSamples, project_radiance, convolve_diffuse, reconstruct

random.seed(17)
samples = GenerateSamples(4, 1)
colors = [[1.0, 2.0, 3.0] for _ in samples]
radiance = project_radiance(samples, colors)
assert len(radiance) == 1
assert all(abs(a - math.sqrt(4 * math.pi) * b) < 1e-14
           for a, b in zip(radiance[0], [1, 2, 3]))
irradiance = convolve_diffuse(radiance)
value = reconstruct(irradiance, [0, 0, 1])
assert all(abs(a - math.pi * b) < 1e-13 for a, b in zip(value, [1, 2, 3]))
assert radiance[0] is not irradiance[0]
assert colors[0] == [1.0, 2.0, 3.0]

Angular probes and the legacy coefficient class

Pixels use hdr[row][column][channel], native float32 RGB for the historical file class. For width w/height h, centers map to u=2(col+.5)/w−1, v=1−2(row+.5)/h, r=hypot(u,v); ignore r>1; theta=pir, phi=atan2(v,u). Right/up/center map to +X/+Y/+Z. Rectangular images stretch the disk. Weight=4pi²/(wh)*sin(theta)/theta, with center limit 1; weights are not renormalized. Finite quadrature approximates integrals and converges with resolution; no universal finite-resolution error bound exists. This is not mirrored-ball photographic mapping or lat-long.

Legacy .file, .width, .height, .hdr, .coeffs are mutable. The nine legacy radiance basis constants are 0.282095 (L0), +0.488603(Y,Z,X), 1.092548(XY,YZ,XZ), 0.315392(3Z²−1), 0.546274(X²−Y²), in indexed order [1,Y,Z,X,XY,YZ,3Z²−1,XZ,X²−Y²]. legacy_to_canonical scales by the ratio of exact to rounded constants and flips indices 1,3,5,7. Never pass these raw legacy coeffs directly to canonical rotation/reconstruction. File errors are not hidden.

The following small probe deliberately samples just the disk center: its weight is 4pi², not a renormalized 4pi sphere integral. This independently checks the pixel-center mapping and illustrates coarse quadrature error, not an accurate constant-environment approximation.

import math
from gem.spherical_harmonics import SPH, project_angular_probe

assert abs(SPH(1, 1, math.pi / 2, 0) + math.sqrt(3 / (4 * math.pi))) < 1e-14
hdr = [[[1.0, 0.0, 0.0]]]
c = project_angular_probe(hdr, 1)
expected = 4 * math.pi * math.pi / math.sqrt(4 * math.pi)
assert abs(c[0][0] - expected) < 1e-14
assert c[0][1:] == [0.0, 0.0] and hdr == [[[1.0, 0.0, 0.0]]]

Analytical active rotation

f_rotated(d)=f_original(R^-1 d): a +90° rotation about +Z moves a +X feature to +Y. L0 stays fixed, L1 is a Cartesian linear-form rotation, and L2 a symmetric traceless tensor transform T′=RTR^T. No directional sampling or reintegration is used. Scalar/RGB channels transform independently. Inputs/storage are preserved; orientation normalization is temporary and specific to this API, not a new Quaternion-wide policy.

from gem.quaternion import quat_from_axis_angle
from gem.spherical_harmonics import rotate_coefficients

# Canonical negative-X basis means coefficient -1 at index 3 points toward +X.
original = [0.0, 0.0, 0.0, -1.0]
q = quat_from_axis_angle([0, 0, 1], 90)
saved = q.data[:]
rotated = rotate_coefficients(original, q)
assert all(abs(a - b) < 1e-14 for a, b in zip(rotated, [0, -1, 0, 0]))
assert original == [0.0, 0.0, 0.0, -1.0] and q.data == saved
assert rotated is not original

Radiance, irradiance and limits

Convolve radiance once with the clamped-cosine kernel to get irradiance. Reconstruct irradiance directly; Lambertian reflected radiance is albedo*irradiance/pi. All coefficients and calculations stay linear until final display conversion. Truncated SH can ring and give negative values; APIs do not silently clamp them. Basis order, quadrature error and ill-conditioned/extreme-value arithmetic are separate limits. Historical scalar helpers do not validate all invalid domains; newer checked finite/layout requirements are not a blanket guarantee for every foreign object/type or overflowing sum.

HDR/GLSL workflow uses example-only lat-long/RGBE adapters and a CPU sphere renderer. Its GLSL is a reference formula, not evidence of GPU execution. See SH guide, legacy, decisions and index.

See the graphics gallery example for an executable visualization.