yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
50e4514
master
1#!/usr/bin/env python3 2""" 3Fourier approximation utility. 4 5Given an analytic expression f(x) and a finite interval [a, b], this script 6samples the function uniformly, computes Fourier series coefficients via the 7FFT, and reports the L2 error of partial sums. Sine and cosine components are 8treated as individual terms and applied in descending order of amplitude to 9highlight the strongest contributions first. 10""" 11 12from __future__import annotations 13 14import argparse 15import numpy as np 16import sys 17from typing import Callable ,Dict ,NamedTuple 18 19 20class FourierTerm (NamedTuple ): 21index :int 22kind :str # "sin" or "cos" 23coefficient :float 24amplitude :float 25phase :float 26angular_frequency :float 27frequency :float 28 29def frac (x ): 30"""Return the fractional part of x in [0, 1).""" 31arr = np .asarray (x ,dtype = float ) 32frac_part = arr - np .floor (arr ) 33if np .isscalar (x ): 34return float (frac_part ) 35return frac_part 36 37# Functions that are allowed inside the user supplied expression. The subset is 38# intentionally small to keep evaluation safe while still being practical. 39_ALLOWED_FUNCS :Dict [str ,object ]= { 40"np" :np , 41"sin" :np .sin , 42"cos" :np .cos , 43"tan" :np .tan , 44"exp" :np .exp , 45"log" :np .log , 46"log10" :np .log10 , 47"log2" :np .log2 , 48"sqrt" :np .sqrt , 49"sinh" :np .sinh , 50"cosh" :np .cosh , 51"tanh" :np .tanh , 52"arcsin" :np .arcsin , 53"arccos" :np .arccos , 54"arctan" :np .arctan , 55"abs" :np .abs , 56"pi" :np .pi , 57"e" :np .e , 58"frac" :frac , 59} 60 61 62def build_function (expression :str )-> Callable [[np .ndarray ],np .ndarray ]: 63"""Create a vectorised callable from the provided expression string.""" 64try : 65code = compile (expression ,"<expression>" ,"eval" ) 66except SyntaxError as exc : 67raise ValueError (f"Invalid function expression: { exc } " )from exc 68 69def func (x :np .ndarray )-> np .ndarray : 70local_dict = dict (_ALLOWED_FUNCS ) 71local_dict ["x" ]= x 72try : 73value = eval (code , {"__builtins__" : {}},local_dict ) 74except Exception as exc :# pragma: no cover - user provided expression 75raise ValueError (f"Error while evaluating expression: { exc } " )from exc 76return np .asarray (value ,dtype = float ) 77 78return func 79 80 81def l2_norm (values :np .ndarray ,length :float )-> float : 82"""Compute the L2 norm using a midpoint rule over the sampling grid.""" 83squared = np .square (values ) 84step = length / values .size 85integral = np .sum (squared )* step 86return float (np .sqrt (integral / length )) 87 88 89def fft_terms ( 90func :Callable [[np .ndarray ],np .ndarray ], 91interval :tuple [float ,float ], 92term_count :int , 93samples :int , 94)-> tuple [np .ndarray ,np .ndarray ,float ,list [FourierTerm ],float ,int ]: 95"""Sample the function and return Fourier terms up to term_count.""" 96start ,end = interval 97if end <= start : 98raise ValueError ("Interval end must be greater than start." ) 99if term_count < 1 : 100raise ValueError ("term_count must be at least 1." ) 101if samples < 2 : 102raise ValueError ("samples must be at least 2." ) 103 104length = end - start 105xs = start + (np .arange (samples )* length / samples ) 106 107fx = func (xs ) 108if fx .shape == (): 109fx = np .full_like (xs ,float (fx )) 110if fx .shape != xs .shape : 111raise ValueError ( 112"Function evaluation did not return values of the expected shape." 113 ) 114 115spectrum = np .fft .rfft (fx )/ samples 116 117constant_term = float (spectrum [0 ].real ) 118 119max_terms = min (term_count ,max (len (spectrum )- 1 ,0 )) 120terms :list [FourierTerm ]= [] 121 122if max_terms == 0 : 123return xs ,fx ,constant_term ,terms ,length ,max_terms 124 125tol = 1e-12 126 127for n in range (1 ,max_terms + 1 ): 128coeff = spectrum [n ] 129an = float (2.0 * coeff .real ) 130bn = float (- 2.0 * coeff .imag ) 131angular_frequency = float (2.0 * np .pi * n / length ) 132frequency = float (n / length ) 133 134if abs (an )> tol : 135amplitude = abs (an ) 136phase = 0.0 if an >= 0 else float (np .pi ) 137terms .append ( 138FourierTerm ( 139index = n , 140kind = "cos" , 141coefficient = an , 142amplitude = amplitude , 143phase = phase , 144angular_frequency = angular_frequency , 145frequency = frequency , 146 ) 147 ) 148 149if abs (bn )> tol : 150amplitude = abs (bn ) 151phase = 0.0 if bn >= 0 else float (np .pi ) 152terms .append ( 153FourierTerm ( 154index = n , 155kind = "sin" , 156coefficient = bn , 157amplitude = amplitude , 158phase = phase , 159angular_frequency = angular_frequency , 160frequency = frequency , 161 ) 162 ) 163 164return xs ,fx ,constant_term ,terms ,length ,max_terms 165 166 167def format_partial_expression ( 168constant_term :float , 169terms :list [FourierTerm ], 170interval_start :float , 171length :float , 172)-> str : 173"""Build a copy-pastable expression for the partial trigonometric sum.""" 174 175tol = 1e-12 176expr_parts :list [str ]= [] 177 178if abs (constant_term )> tol : 179expr_parts .append (f" { constant_term :.6e } " ) 180 181def append_component (components :list [str ],coeff :float ,func :str ,argument :str )-> None : 182if abs (coeff )<= tol : 183return 184base = f" { abs ( coeff ):.6e } * { func } ( { argument } )" 185if components : 186sign = "+" if coeff >= 0 else "-" 187components .append (f" { sign } { base } " ) 188else : 189components .append (base if coeff >= 0 else f"- { base } " ) 190 191for term in terms : 192omega = 2.0 * np .pi * term .index / length 193if abs (interval_start )<= tol : 194argument = f" { omega :.6e } *x" 195elif interval_start < 0 : 196argument = f" { omega :.6e } *(x + { abs ( interval_start ):.6e } )" 197else : 198argument = f" { omega :.6e } *(x - { interval_start :.6e } )" 199func_name = "sin" if term .kind == "sin" else "cos" 200append_component (expr_parts ,term .coefficient ,func_name ,argument ) 201 202if not expr_parts : 203return "0" 204 205return " " .join (expr_parts ) 206 207 208def parse_args (argv :list [str ]| None = None )-> argparse .Namespace : 209parser = argparse .ArgumentParser ( 210description = ( 211"Approximate a real-valued function on [start, end] using a Fourier " 212"series derived from FFT samples and report the L2 error of the " 213"first N partial sums (sorted by amplitude)." 214 ) 215 ) 216parser .add_argument ( 217"expression" , 218help = ( 219"Function expression in terms of x. Use numpy-style syntax, e.g. " 220"'sin(x) + 0.5*cos(3*x)'." 221 ), 222 ) 223parser .add_argument ( 224"start" , 225type = float , 226help = "Beginning of the interval for the approximation." , 227 ) 228parser .add_argument ( 229"end" , 230type = float , 231help = "End of the interval for the approximation." , 232 ) 233parser .add_argument ( 234"--terms" , 235type = int , 236default = 10 , 237help = "Number of Fourier harmonics to include (default: 10)." , 238 ) 239parser .add_argument ( 240"--samples" , 241type = int , 242default = 2048 , 243help = "Number of uniform samples for the FFT (default: 2048)." , 244 ) 245parser .add_argument ( 246"--relative" , 247action = "store_true" , 248help = "Report the relative L2 error in addition to the absolute error." , 249 ) 250if argv is None : 251argv = sys .argv [1 :] 252if not argv : 253parser .print_help (sys .stderr ) 254parser .exit (1 ) 255return parser .parse_args (argv ) 256 257 258def main (argv :list [str ]| None = None )-> int : 259args = parse_args (argv ) 260try : 261func = build_function (args .expression ) 262xs ,fx ,constant_term ,terms ,length ,available_harmonics = fft_terms ( 263func = func , 264interval = (args .start ,args .end ), 265term_count = args .terms , 266samples = args .samples , 267 ) 268except ValueError as exc : 269f"Error: { exc } " ,file = sys .stderr ) 270return 1 271 272base_norm = l2_norm (fx ,length ) 273 274if args .terms > available_harmonics : 275f"Warning: Requested { args . terms } harmonics but only { available_harmonics } available with { args . samples } samples." , 277file = sys .stderr , 278 ) 279 280theta = 2.0 * np .pi * (xs - args .start )/ length 281sorted_terms = sorted (terms ,key = lambda term :abs (term .amplitude ),reverse = True ) 282available_components = len (sorted_terms ) 283 284if args .terms > available_components : 285f"Warning: Requested { args . terms } components but only { available_components } available from the sampled harmonics." , 287file = sys .stderr , 288 ) 289 290sorted_terms = sorted_terms [:args .terms ] 291 292partial = np .full_like (fx ,constant_term ) 293cumulative_terms :list [FourierTerm ]= [] 294trig_cache :dict [int ,tuple [np .ndarray ,np .ndarray ]]= {} 295 296header = "Terms" .rjust (5 )+ " " + "L2 error" .rjust (14 ) 297if args .relative : 298header += " " + "Rel. L2 error" .rjust (14 ) 299header ) 300"-" * len (header )) 301 302f"Constant term: { constant_term :.6e } " ) 303"Terms are sorted by descending amplitude. Each line is a sine or cosine component with params (amplitude, phase, frequency); phase in radians, frequency in cycles per unit." 305 ) 306 307for idx ,term in enumerate (sorted_terms ,start = 1 ): 308cos_sin = trig_cache .get (term .index ) 309if cos_sin is None : 310angles = term .index * theta 311cos_sin = (np .cos (angles ),np .sin (angles )) 312trig_cache [term .index ]= cos_sin 313cos_n ,sin_n = cos_sin 314if term .kind == "cos" : 315partial += term .coefficient * cos_n 316else : 317partial += term .coefficient * sin_n 318error = l2_norm (fx - partial ,length ) 319cumulative_terms .append (term ) 320line = f" { idx :5d } { error :14.6e } " 321if args .relative : 322if base_norm > 0.0 : 323rel_error = error / base_norm 324else : 325rel_error = float ("nan" )if error > 0 else 0.0 326line += f" { rel_error :14.6e } " 327term_info = ( 328f"n= { term . index } { term . kind } (coeff { term . coefficient :.6e } , amp { term . amplitude :.6e } , phase { term . phase :.6e } , freq { term . frequency :.6e } )" 329 ) 330line += f" { term_info } " 331line ) 332expression = format_partial_expression ( 333constant_term , 334cumulative_terms , 335args .start , 336length , 337 ) 338f" expr: { expression } " ) 339 340f"Interval length: { length :.6g } , samples: { args . samples } , base L2 norm: { base_norm :.6e } " 342 ) 343"Note: errors use a midpoint-rule approximation on the sampling grid." ) 344return 0 345 346 347if __name__ == "__main__" : 348sys .exit (main ())