├── arrow_animation.py ├── core ├── __pycache__ │ ├── fourier_drawer.cpython-37.pyc │ ├── fourier_numerical_approximator.cpython-37.pyc │ └── generate_points.cpython-37.pyc ├── fourier_drawer.py ├── fourier_numerical_approximator.py └── generate_points.py ├── data ├── fourier.pts └── fourier.svg ├── demos ├── cake.gif ├── eid.gif ├── fourier arrow.gif ├── fourier evolve.gif ├── heart.gif ├── mosque.gif ├── spiral.gif └── thanks.gif ├── evolution_demo.py ├── examples ├── __pycache__ │ └── bezier.cpython-37.pyc ├── bezier.py └── generate_joseph_fourier_portrait.py ├── readme.md └── requirements.txt /arrow_animation.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script is responsible for generating arrow animations 3 | 1) arrow animation uses scipy.fft to get the frequency components 4 | 2) adds the frequency components tip to tail as rotating vectors 5 | """ 6 | import numpy as np 7 | 8 | from core.fourier_drawer import FourierDrawer 9 | from core.generate_points import get_points 10 | from examples.bezier import get_bezier_curve, get_random_points 11 | 12 | if __name__ == "__main__": 13 | ######## try one of those examples ######## 14 | ########################################################################################### 15 | # Example 1: heart 16 | # points = [] 17 | # for arg in np.arange(0, 2 * np.pi, .01): 18 | # points.append(complex(16 * np.sin(arg) ** 3, 1 - (13 * np.cos(arg) - 5 * np.cos(2 * arg) - 2 * np.cos(3 * arg) - np.cos(4 * arg)))) 19 | # points = np.array(points) * .05 20 | ########################################################################################### 21 | # Example 2: square 22 | # """ 23 | # (-1,1) 24 | # (-1,-1) 25 | # (1,-1) 26 | # (1,1) 27 | # (-1,1) 28 | # """ 29 | # points = [-1 + y * 1j for y in np.linspace(1, -1, 200)][:-1] 30 | # points += [y + -1 * 1j for y in np.linspace(-1, 1, 200)][:-1] 31 | # points += [1 + y * 1j for y in np.linspace(-1, 1, 200)][:-1] 32 | # points += [y + 1j for y in np.linspace(1, -1, 200)] 33 | # points = np.array(points) * .7 34 | ########################################################################################### 35 | # Example 3: a random bezier curve (closed-path) 36 | # ccw_sorted_points = get_random_points(n=7, scale=1) 37 | # ccw_sorted_points = np.vstack([ccw_sorted_points, ccw_sorted_points[0]]) 38 | # x, y, _ = get_bezier_curve(ccw_sorted_points) 39 | # points = x - .5 + 1j * (y - .5) 40 | ########################################################################################### 41 | # Example 4: from an svg file 42 | points = get_points("data/fourier.pts") 43 | ########################################################################################### 44 | 45 | FOUR = FourierDrawer(write_path="temp") 46 | FOUR.animate(points, top_perc=1, ) # lower top_perc makes it faster 47 | -------------------------------------------------------------------------------- /core/__pycache__/fourier_drawer.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/core/__pycache__/fourier_drawer.cpython-37.pyc -------------------------------------------------------------------------------- /core/__pycache__/fourier_numerical_approximator.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/core/__pycache__/fourier_numerical_approximator.cpython-37.pyc -------------------------------------------------------------------------------- /core/__pycache__/generate_points.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/core/__pycache__/generate_points.cpython-37.pyc -------------------------------------------------------------------------------- /core/fourier_drawer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This holds a simple class for drawing our animations 3 | 1) uses opencv for 2d drawing 4 | 2) creates a canves of 2500x2500 with normalized coordiantes for graphing functions 5 | 3) uses scipy.fft to obtain the discrete fourier transform to be fed to arrow animation function (check "animate" method here) 6 | 4) uses FourierApproximator class to generate the evolution animation (check "evolve" method here) 7 | """ 8 | import os 9 | from math import atan2 10 | from random import shuffle 11 | 12 | import cmapy 13 | import cv2 14 | import numpy as np 15 | from numpy import pi, cos, sin 16 | from scipy.fft import fft 17 | 18 | from core.fourier_numerical_approximator import FourierApproximator 19 | 20 | 21 | class FourierDrawer: 22 | """ 23 | A very simple rendering class for fourier animation, a 2D square screen limited by lower_limit and upper_limit 24 | this class can animate complex functions (assumed to be a closed loop), 25 | since fourier series operates on periodic functions, we can interpret a closed-loop as a periodic function with the angle repeating every 2*pi, for example a point on a circle at angle 0 is the same as any point at n*2*pi 26 | """ 27 | 28 | def __init__(self, dpi=2500, lower_limit=-1, upper_limit=1, trace=True, write_path="temp"): 29 | """ 30 | lower_limit and upper_limit represents a normalized coordinates that will be mapped to dpi resolution 31 | :param dpi: the resolution of the rendered image 32 | :param lower_limit: the lower left limit for the 2D drawing screen 33 | :param upper_limit: the upper right limit for the 2D drawing screen 34 | :param trace: caching the points with every frame (needed for vector animation) 35 | :param write_path: path to write images to 36 | """ 37 | self.dpi = dpi 38 | self.dim_min = lower_limit 39 | self.dim_max = upper_limit 40 | self.diff = upper_limit - lower_limit 41 | self.trace = trace 42 | self.history = [] 43 | self.source_zoom = .05 44 | self.dist_zoom = .2 45 | self.write_path = write_path 46 | if not os.path.exists(write_path): 47 | os.mkdir(write_path) 48 | 49 | cv2.namedWindow("Animation", cv2.WINDOW_NORMAL) # the drawing window 50 | cv2.resizeWindow("Animation", 800, 800) # my poor HD screen 51 | 52 | def un_normalized_coords(self, normalized): 53 | """ 54 | from the normalized coordinates to the screen coordinates 55 | :param normalized: the point in normalized coordinates, 56 | if it's a np.ndarray (point_x, point_y) 57 | else it's a magnitude 58 | :return: scale and return the point based on the dpi 59 | """ 60 | if type(normalized) is np.ndarray: # a pair 61 | return (normalized - self.dim_min) / self.diff * self.dpi 62 | else: 63 | return normalized * self.dpi * .5 # (magnitude) value ranges from 0 to 1 to fit in screen 64 | 65 | def normalized_coords(self, un_normalized): 66 | """ 67 | convert a (point_x, point_y) to normalized coordinates 68 | :param un_normalized: scaled point to be normalized 69 | :return: normalized point 70 | """ 71 | return (un_normalized - .5 * self.dpi) / self.dpi * self.diff 72 | 73 | @staticmethod 74 | def get_arrow_hooks(point1, point2): 75 | """ 76 | for a line defined by 2 points, draw an arrow hook at point2 77 | :param point1: start point of a line 78 | :param point2: end point of a line 79 | :return: both hooks of => with point2 as head 80 | """ 81 | # create the arrow hooks 82 | angle = atan2(point1[1] - point2[1], point1[0] - point2[0]) # angle in radians 83 | 84 | hook1 = (int(point2[0] + 8 * cos(angle + pi / 8)), 85 | int(point2[1] + 8 * sin(angle + pi / 8))) 86 | 87 | hook2 = (int(point2[0] + 8 * cos(angle - pi / 8)), 88 | int(point2[1] + 8 * sin(angle - pi / 8))) 89 | return hook1, hook2 90 | 91 | def draw_component(self, canvas, magnitude, phase_rad, old_shift, circle_color, text_color): 92 | """ 93 | draw fourier component 94 | :param canvas: the drawing canvas to draw a component to 95 | :param magnitude: the magnitude of the component 96 | :param phase_rad: the phase for the component in radians 97 | :param old_shift: the last point position 98 | :param circle_color: BGR color of a circle 99 | :param text_color: BGR the color of the text 100 | :return: 101 | """ 102 | # un normalize for drawing 103 | old_shift = self.un_normalized_coords(old_shift) 104 | magnitude = self.un_normalized_coords(magnitude) 105 | 106 | # 1) magnitude of a frequency component (a circle with radius of magnitude) 107 | thickness = int(np.log(100000 * magnitude / self.dpi)) + 1 # thickness based on strength 108 | cv2.circle(canvas, self.as_cv_tuple(old_shift), int(magnitude), color=circle_color, thickness=thickness, lineType=cv2.LINE_AA) 109 | 110 | # the new point, also the end of the arrow 111 | new_shift = np.array([(old_shift[0] + magnitude * cos(phase_rad)), 112 | (old_shift[1] + magnitude * sin(phase_rad))]) 113 | 114 | # 2) an arrow representing the phase shift of each component + time shift (a linear phase shift) 115 | thickness = max(int(np.log(10000 * magnitude / self.dpi)), 1) # thickness based on strength 116 | cv2.line(canvas, self.as_cv_tuple(old_shift), self.as_cv_tuple(new_shift), color=text_color, thickness=thickness, lineType=cv2.LINE_AA) 117 | 118 | hook1, hook2 = self.get_arrow_hooks(old_shift, new_shift) 119 | cv2.line(canvas, hook1, self.as_cv_tuple(new_shift), color=text_color, thickness=thickness, lineType=cv2.LINE_AA) 120 | cv2.line(canvas, hook2, self.as_cv_tuple(new_shift), color=text_color, thickness=thickness, lineType=cv2.LINE_AA) 121 | 122 | return self.normalized_coords(new_shift) 123 | 124 | @staticmethod 125 | def as_cv_tuple(point): 126 | """ 127 | convert to opencv tuble 128 | :param point: floating np.array 129 | :return: convert to int tuple 130 | """ 131 | return tuple(point.astype(int)) 132 | 133 | def draw_components(self, magnitudes, phases, frequencies, normalized_time, colors): 134 | """ 135 | draw the whole frame at a given point in time (this is used for the animation), the animation starts at normalized_time = 0 to normalized_time = 1 136 | :param magnitudes: the k components used to animate the drawing (more components = more circles = slower animation) 137 | :param phases: the phase shift of each component (to match the animation timing and draw the exact shape) in radian 138 | :param frequencies: the frequency of every component (integral multiple of fundamental frequency 1,2,3,4,5) 139 | :param normalized_time: from 0 to 1, start to the end of the animation video frames 140 | :param colors: list of consistent colors 141 | :return: the rendered frame 142 | 143 | magnitudes, phases, frequencies are of the same length 144 | """ 145 | canvas = np.zeros((self.dpi, self.dpi, 3), dtype=np.uint8) # init the frame 146 | 147 | current_shift = np.array((0, 0)) # starting at the center 148 | for color_idx, (frequency, magnitude, phase_rad) in enumerate(zip(frequencies, magnitudes, phases)): 149 | # for every frequency, magnitude, phase_rad draw the circle, the arrow based on the current shift + phase shift added plus the frequency 150 | angular_frequency = 2 * pi * frequency 151 | time_shift = angular_frequency * normalized_time # at time normalized_time scale by the frequency: 2*pi*f*t 152 | current_shift = self.draw_component(canvas, 153 | magnitude, 154 | phase_rad + time_shift, 155 | current_shift, colors[color_idx], colors[color_idx * 2]) 156 | 157 | if self.trace: 158 | # keep track of the points at time < normalized_time, cache them in history 159 | background = np.zeros_like(canvas) 160 | canvas = cv2.addWeighted(canvas, .5, background, .5, 0.0) # make the arrows + circles half transparent 161 | self.history.append( 162 | self.as_cv_tuple( 163 | self.un_normalized_coords(current_shift) # the function value at time = normalized_time = f(t) = value 164 | )) 165 | 166 | # draw every point in history, to draw the 2d function in the red color 167 | for point1, point2 in zip(self.history[:-1], self.history[1:]): 168 | cv2.line(canvas, point1, point2, color=(0, 0, 255), thickness=10, lineType=cv2.LINE_AA) 169 | 170 | self.add_zoom_box(canvas) 171 | 172 | return canvas 173 | 174 | def add_zoom_box(self, canvas): 175 | """ 176 | add the zoom box on the lower left, to zoom at the point being drawn 177 | :param canvas: the drawing canvas 178 | """ 179 | # zoomed 180 | target_size = int(self.dist_zoom * self.dpi) 181 | source_zoom = self.source_zoom * self.dpi 182 | 183 | # make a crop 184 | left_zoomed = int(self.history[-1][0] - source_zoom) 185 | right_zoomed = int(self.history[-1][0] + source_zoom) 186 | top_zoomed = int(self.history[-1][1] - source_zoom) 187 | bottom_zoomed = int(self.history[-1][1] + source_zoom) 188 | 189 | # clip if it goes beyond 190 | left_zoomed = max(0, left_zoomed) 191 | top_zoomed = max(0, top_zoomed) 192 | right_zoomed = min(self.dpi, right_zoomed) 193 | bottom_zoomed = min(self.dpi, bottom_zoomed) 194 | 195 | # crop it 196 | zoom_box = canvas[top_zoomed:bottom_zoomed, left_zoomed:right_zoomed] 197 | 198 | # zero padding if the crop goes outside the boundaries 199 | hor_padding = int(source_zoom * 2 - zoom_box.shape[0]) 200 | ver_padding = int(source_zoom * 2 - zoom_box.shape[1]) 201 | 202 | zoom_box = np.pad(zoom_box, 203 | [(ver_padding // 2, ver_padding // 2 + ver_padding % 2) 204 | , (hor_padding // 2, hor_padding // 2 + hor_padding % 2), 205 | (0, 0)]) # zero padding 206 | 207 | zoom_box = cv2.resize(zoom_box, (target_size, target_size)) 208 | zoom_box[0:10, :] = (255, 255, 255) 209 | zoom_box[-11:-1, :] = (255, 255, 255) 210 | zoom_box[:, 0:10] = (255, 255, 255) 211 | zoom_box[:, -11:-1] = (255, 255, 255) 212 | canvas[-target_size:, -target_size:] = zoom_box # place the zoom box at the bottom right 213 | 214 | def animate(self, time_signal, top_perc=1.0, cmap='hsv'): 215 | """ 216 | EXAMPLE OUTPUT VIDEO IN heart.mp4 217 | This function draw the animated vector plot, where fourier components are vectors added cumulatively, 218 | to produce it we get the discrete fourier transform for the 2d complex function and we animate the components as time goes 219 | NOTE: discrete fourier transform takes n points to produce n frequency components, 220 | to make it efficient we only consider the top k components for reconstruction 221 | 222 | :param time_signal: signal in time domain (a sequence of points (complex for 2d)) 223 | :param top_perc: percentage of the strongest frequency components to keep , this value ranges from 0 --> 1, 0 means take nothing, 1 takes all components 224 | :param cmap: any matplotlib cmap 225 | """ 226 | 227 | freq_desc = fft(time_signal) / len(time_signal) # complex array 228 | magnitudes = np.abs(freq_desc) 229 | phases = np.angle(freq_desc) 230 | time_steps = len(magnitudes) 231 | ########################################################### 232 | # dropping zero components for better color distribution 233 | tok_k_count = int(len(magnitudes) * top_perc) # select the top k strongest components and neglecting the rest 234 | frequencies = np.sort(magnitudes.argsort()[-tok_k_count:][::-1]) # top k frequencies, frequency multiples actually 235 | magnitudes = magnitudes[frequencies] # top k magnitudes 236 | phases = phases[frequencies] # top k phases 237 | ############################################################ 238 | # using the color map to color the components for a better visual experience 239 | colors = [cmapy.color(cmap, round(idx)) 240 | for idx in np.linspace(0, 255, len(frequencies) * 2) 241 | ] # linearly spaced colors 242 | shuffle(colors) 243 | ########################################################### 244 | # the animation simulation 245 | for time in range(time_steps + 100): # extra 100 static frames in order to give some time for the viewer to see the full image in a video 246 | if time < time_steps: 247 | canvas = self.draw_components(magnitudes, phases, frequencies, time / time_steps, colors) 248 | cv2.imshow("Animation", canvas) 249 | if self.write_path: 250 | cv2.imwrite(os.path.join(self.write_path, f"{time:05d}.png"), canvas) 251 | cv2.waitKey(10) 252 | else: 253 | cv2.imwrite(os.path.join(self.write_path, f"{time:05d}.png"), canvas) 254 | 255 | print("PRESS ANY KEY TO EXIT") 256 | cv2.waitKey() 257 | 258 | def evolve(self, time_signal, num_components_step=5, draw_original=False, cmap='hsv', max_components=400): 259 | """ 260 | evolve animation uses the continuous fourier series approximation we made in core i.e FourierApproximator 261 | why we can't use fft from scipy? 262 | since fft is a discrete fast fourier transform it expects n input points to produce n frequency components, but what we need here to produce the evolution animation 263 | i.e. to draw the reconstructed function for the first component, 264 | then draw it for the first 2 components and so on, until we have a fair approximation of the function 265 | but using fft produces n frequency components for exactly n input points in time and they aren't guaranteed to reconstruct the function iteratively, you may try it :), 266 | discrete fourier transform when used to reconstruct the original input it only guarantees the original points when we used ALL OF THE FREQUENCY COMPONENTS 267 | 268 | the images will be saved to the write path for every frame rendered, the reconstruction will eventually diverge 269 | :param cmap: the color map during evolution 270 | :param draw_original: whether to draw the original curve 271 | :param num_components_step: number of components taken in each step 272 | :param time_signal: signal in time domain (a sequence of points (complex for 2d)) 273 | :param max_components: max components to calculate 274 | :return: 275 | """ 276 | f_approx = FourierApproximator(time_signal) 277 | # f_approx = BigFourierApproximator(time_signal) 278 | # f_approx can bring the top k frequency components and it also caches them for further calls 279 | 280 | orginal_function_canvas = np.zeros((self.dpi, self.dpi, 3), dtype=np.uint8) 281 | x_time = np.real(time_signal) 282 | y_time = np.imag(time_signal) 283 | 284 | # draw the original input function 285 | if draw_original: 286 | self.draw_to_canvas(orginal_function_canvas, x_time, y_time, color=(0, 255, 0)) 287 | 288 | # draw the reconstructed function for 10 to 2000 components 289 | for i in range(0, max_components, num_components_step): 290 | temp_canvas = orginal_function_canvas.copy() 291 | restored_points = f_approx.restore_up_to(i) 292 | 293 | x_time = [point.real for point in restored_points] 294 | y_time = [point.imag for point in restored_points] 295 | self.draw_to_canvas(temp_canvas, x_time, y_time, color=cmapy.color(cmap, (i + 1) / max_components)) 296 | cv2.putText(temp_canvas, f"Harmonics: {i}", 297 | (int(temp_canvas.shape[0] * .75), int(temp_canvas.shape[1] * .05)), 298 | cv2.FONT_HERSHEY_SIMPLEX, 2, (255, 255, 255), 7, cv2.LINE_AA) 299 | 300 | cv2.imshow("Animation", temp_canvas) 301 | if self.write_path: 302 | cv2.imwrite(os.path.join(self.write_path, f"{i:05d}.png"), temp_canvas) 303 | 304 | cv2.waitKey(1) 305 | 306 | print("PRESS ANY KEY TO EXIT") 307 | cv2.waitKey() 308 | 309 | def draw_to_canvas(self, canvas, x_time, y_time, color): 310 | for point in zip(x_time, y_time): 311 | point = self.un_normalized_coords(np.array(point)) 312 | 313 | cv2.circle(canvas, self.as_cv_tuple(point), 5, color=color, thickness=-1) 314 | -------------------------------------------------------------------------------- /core/fourier_numerical_approximator.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file holds the mathematical core for generating fourier series animation, 3 | this involves 4 | 1) numerical integration 5 | 2) computing coefficients c_n 6 | 3) caching coefficients 7 | 4) restores the function up to the k-th coefficient 8 | """ 9 | from functools import partial 10 | 11 | import numpy as np 12 | from scipy import integrate 13 | 14 | 15 | def complex_quadrature(func, a, b, ): 16 | """ 17 | complex function integration (done numerically), taken from https://stackoverflow.com/questions/5965583/use-scipy-integrate-quad-to-integrate-complex-numbers 18 | :param func: any complex function (in our case the f function for fourier transform) 19 | :param a: the lower limit for our integration 20 | :param b: the upper limit for our integration 21 | :return: the integral evaluation value 22 | """ 23 | 24 | def real_func(x): 25 | """ 26 | evaluate the real part of a complex function at x 27 | :param x: the input points 28 | :return: the real part of func(x) 29 | """ 30 | return np.real([func(x_) for x_ in x]) 31 | 32 | def imag_func(x): 33 | """ 34 | evaluate the imaginary part of a complex function at x 35 | :param x: the input points 36 | :return: the imaginary part of func(x) 37 | """ 38 | return np.imag([func(x_) for x_ in x]) 39 | 40 | real_integral = integrate.quadrature(real_func, a, b, maxiter=1500) # integrate real 41 | imag_integral = integrate.quadrature(imag_func, a, b, maxiter=1500) # integrate imaginary 42 | 43 | # integrate.quadrature() 44 | 45 | if real_integral[1:][0] > 1e-3: 46 | print("High integration error", real_integral[1:][0]) 47 | 48 | if imag_integral[1:][0] > 1e-3: 49 | print("High integration error", imag_integral[1:][0]) 50 | 51 | 52 | return real_integral[0] + 1j * imag_integral[0] # the complex output 53 | 54 | 55 | class FourierApproximator: 56 | """ 57 | coefficient function for fourier transform, view it on any latex viewer https://latex.codecogs.com/eqneditor/editor.php 58 | c_n = \frac{1}{P} \int_{t_0}^{t_0 + P} f(t) \exp \left( \frac{-i 2\pi n t}{P}\right) \mathrm{d}t 59 | P is the period, since we are given points to approximate using fourier we can simply set P = 1 60 | """ 61 | 62 | def __init__(self, points): 63 | self.P = 1 # any value will work 64 | 65 | self.points = points # samples from the complex 2d function 66 | self.cache = {} # caching fourier coefficients as we go 67 | 68 | def fourier_integral_sample(self, t, n): 69 | """ 70 | in order to compute c_n numerically (numerical integration) we need to get samples of the function being integrated at different points t 71 | this expression 72 | \frac{1}{P} f(t) \exp \left( \frac{-i 2\pi n t}{P}\right) 73 | :param t: for point t in time 74 | :param n: for coefficient k 75 | :return: a sample d_n(t) 76 | """ 77 | mapped_index = t / self.P # for point t in time, get the index of the nearest point of self.points 78 | f_t = self.points[int(len(self.points) * mapped_index)] 79 | 80 | exp_part = np.exp(-2j * np.pi * n * t / self.P) 81 | return (1 / self.P) * f_t * exp_part 82 | 83 | def fourier_series(self, c_n_list, t, ): 84 | """ 85 | this applies the fourier series reconstruction by computing 86 | S_N(t) = \sum_{n=-N}^N c_n \exp \left( \frac{i 2\pi n t}{P}\right) 87 | using c_n fourier coefficients 88 | :param c_n_list: a list of top 2*k + 1 fourier exponential coefficients (2*k complex conjugates) (returned from get_coefficients) 89 | :param t: at specific point t get the value of the function 90 | :return: the value of the function resembled by points self.points, simply this is the fourier approximation for f(t) using top k fourier coefficients 91 | """ 92 | return sum(c_n * np.exp(2j * np.pi * n * t / self.P) 93 | for n, c_n in c_n_list) 94 | 95 | def get_coefficients(self, k): 96 | """ 97 | get top k fourier coefficients for the discrete function have self.points samples 98 | :param k: the number of components, more k = better approximation, in general :) 99 | :return: a list of top 2*k + 1 fourier exponential coefficients (2*k complex conjugates) 100 | """ 101 | cn = [] 102 | for i in range(-k, k + 1): 103 | if i in self.cache: 104 | c_n = self.cache[i] # don't recompute the same coefficient, get if from the cache 105 | else: 106 | 107 | # c_n = \frac{1}{P} \int_{t_0}^{t_0 + P} f(t) \exp \left( \frac{-i 2\pi n t}{P}\right) \mathrm{d}t 108 | c_n = complex_quadrature(partial(self.fourier_integral_sample, n=i), 109 | 0, 110 | self.P) 111 | self.cache[i] = c_n 112 | cn.append((i, c_n)) 113 | return cn 114 | 115 | def restore_up_to(self, k): 116 | """ 117 | we can use fourier coefficients to restore the signal using first k components 118 | :param k: the number of components, more k = better approximation, theoretically, but here it diverges because of numerical stability issues 119 | :return: the points reconstructed from fourier coefficients computed using the self.points 120 | """ 121 | cn = self.get_coefficients(k) # get top k coefficients 122 | return [ 123 | self.fourier_series(cn, t) 124 | for t in np.linspace(0, self.P, len(self.points)) 125 | ] 126 | -------------------------------------------------------------------------------- /core/generate_points.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script is responsible for 3 | 1) reading a svg file with paths, 4 | 2) those paths are represented as a complex function and saved to the disk with the help of joblib 5 | 3) the drawing code for arrow animation and evolution can work with points returned by "get_points" funtion here 6 | """ 7 | import random 8 | from xml.dom import minidom 9 | 10 | import cv2 11 | import joblib 12 | import numpy as np 13 | from svg.path import parse_path 14 | 15 | 16 | def get_point_at(path, distance, scale, offset): 17 | pos = path.point(distance) 18 | pos += offset 19 | pos *= scale 20 | return pos.real, pos.imag 21 | 22 | 23 | def points_from_path(path, density, scale, offset): 24 | step = int(path.length() * density) 25 | last_step = step - 1 26 | 27 | if last_step == 0: 28 | yield get_point_at(path, 0, scale, offset) 29 | return 30 | 31 | for distance in range(step): 32 | yield get_point_at( 33 | path, distance / last_step, scale, offset) 34 | 35 | 36 | def points_from_doc(doc, density=5, scale=1, offset=0): 37 | offset = offset[0] + offset[1] * 1j 38 | points = [] 39 | for element in doc.getElementsByTagName("path"): 40 | for path in parse_path(element.getAttribute("d")): 41 | points.extend(points_from_path( 42 | path, density, scale, offset)) 43 | 44 | return points 45 | 46 | 47 | def generate_points_from_svg(svg_file_path): 48 | # read the SVG file 49 | doc = minidom.parse(svg_file_path) 50 | points = points_from_doc(doc, density=1, scale=5, offset=(0, 5)) 51 | doc.unlink() 52 | 53 | points = np.array(points) 54 | min_p, max_p = points.min(axis=0), points.max(axis=0) 55 | points = (points - min_p) / (max_p - min_p) 56 | points = points[:, ::-1] 57 | points[:, 0] = points[:, 0] * .9 + .05 58 | points[:, 1] = points[:, 1] * .7 + .15 59 | joblib.dump((points, 1.0), svg_file_path.replace(".svg", ".pts"), compress=5) 60 | 61 | 62 | def animate_points(pts_path): 63 | cv2.namedWindow("Animation", cv2.WINDOW_NORMAL) 64 | cv2.resizeWindow("Animation", 800, 800) 65 | 66 | path, aspect = joblib.load(pts_path) 67 | 68 | dim = 1000 69 | draw = np.zeros((int(aspect * dim), dim, 3)).astype(np.uint8) 70 | path = (path * (aspect * dim, dim)).astype(int) 71 | for p in path: 72 | color = (random.choices(range(256), k=3)) 73 | 74 | cv2.circle(draw, tuple(p[::-1]), 5, color=color, thickness=-1) 75 | 76 | cv2.imshow("Animation", draw) 77 | cv2.waitKey(1) 78 | 79 | 80 | def get_points(pts_path): 81 | path, aspect = joblib.load(pts_path) 82 | path = path * 2 - 1 83 | path = (path * (1, 1 / aspect)) 84 | return 1j * path[:, 0] + path[:, 1] 85 | 86 | -------------------------------------------------------------------------------- /data/fourier.pts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/data/fourier.pts -------------------------------------------------------------------------------- /data/fourier.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /demos/cake.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/cake.gif -------------------------------------------------------------------------------- /demos/eid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/eid.gif -------------------------------------------------------------------------------- /demos/fourier arrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/fourier arrow.gif -------------------------------------------------------------------------------- /demos/fourier evolve.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/fourier evolve.gif -------------------------------------------------------------------------------- /demos/heart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/heart.gif -------------------------------------------------------------------------------- /demos/mosque.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/mosque.gif -------------------------------------------------------------------------------- /demos/spiral.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/spiral.gif -------------------------------------------------------------------------------- /demos/thanks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/demos/thanks.gif -------------------------------------------------------------------------------- /evolution_demo.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script is responsible for evolving animations using fourier series 3 | 1) this uses FourierApproximator class to approximate fourier coefficients using numerical integration 4 | 2) in each frame the function is reconstructed until N components from step 1) 5 | """ 6 | import numpy as np 7 | 8 | from core.fourier_drawer import FourierDrawer 9 | from core.generate_points import get_points 10 | from examples.bezier import get_bezier_curve, get_random_points 11 | 12 | if __name__ == "__main__": 13 | ######## try one of those examples ######## 14 | ########################################################################################### 15 | # Example 1: heart 16 | # points = [] 17 | # for arg in np.arange(0, 2 * np.pi, .01): 18 | # points.append(complex(16 * np.sin(arg) ** 3, 1 - (13 * np.cos(arg) - 5 * np.cos(2 * arg) - 2 * np.cos(3 * arg) - np.cos(4 * arg)))) 19 | # points = np.array(points) * .05 20 | ########################################################################################### 21 | # Example 2: square 22 | # """ 23 | # (-1,1) 24 | # (-1,-1) 25 | # (1,-1) 26 | # (1,1) 27 | # (-1,1) 28 | # """ 29 | # points = [-1 + y * 1j for y in np.linspace(1, -1, 200)][:-1] 30 | # points += [y + -1 * 1j for y in np.linspace(-1, 1, 200)][:-1] 31 | # points += [1 + y * 1j for y in np.linspace(-1, 1, 200)][:-1] 32 | # points += [y + 1j for y in np.linspace(1, -1, 200)] 33 | # points = np.array(points) * .7 34 | ########################################################################################### 35 | # Example 3: a random bezier curve (closed-path) 36 | # ccw_sorted_points = get_random_points(n=7, scale=1) 37 | # ccw_sorted_points = np.vstack([ccw_sorted_points, ccw_sorted_points[0]]) 38 | # x, y, _ = get_bezier_curve(ccw_sorted_points) 39 | # points = x - .5 + 1j * (y - .5) 40 | ########################################################################################### 41 | # Example 4: from an svg file 42 | points = get_points("data/fourier.pts") # set max_components=300, num_components_step =1, this will write 300 frames with one new frequency component added with each frame 43 | ########################################################################################### 44 | 45 | FOUR = FourierDrawer(write_path="temp") 46 | FOUR.evolve(points, num_components_step=1, max_components=300) 47 | -------------------------------------------------------------------------------- /examples/__pycache__/bezier.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammed-elkomy/fourier-anim-python/28fd5a661b9fc35af80653f1d7b7db9251d2b74a/examples/__pycache__/bezier.cpython-37.pyc -------------------------------------------------------------------------------- /examples/bezier.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.special import binom 3 | import matplotlib.pyplot as plt 4 | 5 | 6 | # I found that on stackoverflow, but reformatted the code a bit to be more clear to me 7 | # https://stackoverflow.com/questions/50731785/create-random-shape-contour-using-matplotlib/50732357 8 | 9 | 10 | def bernstein(resolution, itr, interval): 11 | """ 12 | direct application of the bezier curve (3rd order) https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Explicit_definition 13 | :param resolution: number of points to represent the resolution 14 | :param itr: the step or the point obtained by the function explained on wikipedia 15 | :param interval: the interval points [0,1] as a range 16 | :return: a term in the function explained on wikipedia, for both x and y 17 | """ 18 | return binom(resolution, itr) * interval ** itr * (1. - interval) ** (resolution - itr) 19 | 20 | 21 | def bezier(anchors, resolution): 22 | """ 23 | direct application of the bezier curve (3rd order) https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Explicit_definition 24 | :param anchors: the anchor points of the bezier curve 25 | :param resolution: number of points to represent the resolution (how many points are generated between two successive points) 26 | :return: the curve points 27 | """ 28 | num_points = len(anchors) 29 | interval = np.linspace(0, 1, num=resolution) # this is t R: [0,1], spanning the curve 30 | curve = np.zeros((resolution, 2)) 31 | for i in range(num_points): 32 | curve += np.outer(bernstein(num_points - 1, i, interval), anchors[i]) 33 | return curve 34 | 35 | 36 | class Segment: 37 | """ 38 | A class to model a bezier segment of a 3rd order polynomail between to successive points 39 | the mathematical model is here: https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Explicit_definition 40 | """ 41 | 42 | def __init__(self, p1, p2, angle1, angle2, **kw): 43 | """ 44 | :param p1: the first point of the segment 45 | :param p2: the second point of the segment 46 | :param angle1: departure angle of the first point 47 | :param angle2: departure angle of the second point 48 | :param kw: extra parameters, mainly "numpoints", and "r" 49 | # numpoints: number of points to represent the resolution (how many points are generated between two successive points) 50 | # r: a random value r used to set the internal points 51 | """ 52 | self.p1 = p1 53 | self.p2 = p2 54 | self.angle1 = angle1 55 | self.angle2 = angle2 56 | self.numpoints = kw.get("numpoints", 100) # number of points to represent the resolution (how many points are generated between two successive points) 57 | radius = kw.get("r", 0.3) # a random value r used to set the internal points 58 | dist = np.sqrt(np.sum((self.p2 - self.p1) ** 2)) 59 | self.radius = radius * dist 60 | self.anchors = np.zeros((4, 2)) 61 | 62 | # outer points 63 | self.anchors[0, :] = self.p1[:] 64 | self.anchors[3, :] = self.p2[:] 65 | 66 | # inner points 67 | self.anchors[1, :] = self.p1 + np.array([self.radius * np.cos(self.angle1), self.radius * np.sin(self.angle1)]) 68 | self.anchors[2, :] = self.p2 + np.array([self.radius * np.cos(self.angle2 + np.pi), self.radius * np.sin(self.angle2 + np.pi)]) 69 | self.curve = bezier(self.anchors, self.numpoints) 70 | 71 | 72 | def get_curve(points, **kw): 73 | """ 74 | computes the curve in a form of segments, a segment is a 3rd order polynomial between every two successive points 75 | :param points: the sequence of points 76 | :param kw: extra parameters, mainly "numpoints", and "r" passed to Segment object 77 | :return: the segments and the curve points 78 | """ 79 | segments = [] 80 | for i in range(len(points) - 1): 81 | seg = Segment(points[i, :2], points[i + 1, :2], points[i, 2], points[i + 1, 2], **kw) 82 | segments.append(seg) 83 | curve = np.concatenate([s.curve for s in segments]) 84 | return segments, curve 85 | 86 | 87 | def ccw_sort(points): 88 | """ 89 | sorting points on counter clockwise order 90 | :param points: numpy points [n,2] 91 | :return: sorted numpy points [n,2], using the angle 92 | """ 93 | shifted_points = points - np.mean(points, axis=0) 94 | slop_angle = np.arctan2(shifted_points[:, 0], shifted_points[:, 1]) 95 | return points[np.argsort(slop_angle), :] 96 | 97 | 98 | def positive_angle(ang): 99 | """ 100 | :param ang: input angle 101 | :return: if the angle is negative, add a full cycle 102 | """ 103 | return (ang >= 0) * ang + (ang < 0) * (ang + 2 * np.pi) 104 | 105 | 106 | def get_bezier_curve(random_points, STEER=0.2, smooth=0, numpoints=100): 107 | """ 108 | given an array of points *a*, create a curve through those points. 109 | :param random_points: counter clockwise sorted points 110 | :param STEER: is a number between 0 and 1 to steer the distance of control points. 111 | :param smooth: is a parameter which controls how "edgy" the curve is, edgy=0 is smoothest. 112 | :param numpoints: the curve resolution between between every two successive points 113 | :return: the bezier curve for the ccw_sorted_points 114 | """ 115 | p = np.arctan(smooth) / np.pi + .5 116 | # random_points = ccw_sort(random_points) 117 | # ccw_sorted_points = np.append(ccw_sorted_points, np.atleast_2d(ccw_sorted_points[0, :]), axis=0) 118 | pairwise_differences = np.diff(random_points, axis=0) 119 | pairwise_angles = np.arctan2(pairwise_differences[:, 1], pairwise_differences[:, 0]) 120 | 121 | pairwise_angles = positive_angle(pairwise_angles) 122 | ang1 = pairwise_angles 123 | ang2 = np.roll(pairwise_angles, 1) 124 | pairwise_angles = p * ang1 + (1 - p) * ang2 + (np.abs(ang2 - ang1) > np.pi) * np.pi 125 | pairwise_angles = np.append(pairwise_angles, [pairwise_angles[0]]) 126 | random_points = np.append(random_points, np.atleast_2d(pairwise_angles).T, axis=1) 127 | segments, curve = get_curve(random_points, r=STEER, numpoints=numpoints) 128 | x, y = curve.T 129 | return x, y, random_points 130 | 131 | 132 | def get_random_points(n=5, scale=0.8, min_dst=None, recur=0): 133 | """ create n random points in the unit square, which are *mindst* apart, then scale them.""" 134 | min_dst = min_dst or .7 / n 135 | random_points = np.random.rand(n, 2) # zero centered 136 | ccw_sorted_points = ccw_sort(random_points) 137 | pairwise_differences = np.diff(ccw_sorted_points, axis=0) 138 | pairwise_distances = np.linalg.norm(pairwise_differences, axis=1) 139 | if np.all(pairwise_distances >= min_dst) or recur >= 200: 140 | return ccw_sorted_points * scale 141 | else: 142 | return get_random_points(n=n, scale=scale, min_dst=min_dst, recur=recur + 1) 143 | 144 | 145 | if __name__ == "__main__": 146 | fig, ax = plt.subplots() 147 | ax.set_aspect("equal") 148 | 149 | rad = 0.2 150 | edgy = 0.05 151 | 152 | for c in np.array([[0, 0], [0, 1], [1, 0], [1, 1]]): 153 | ccw_sorted_points = get_random_points(n=5, scale=1) + c 154 | x, y, _ = get_bezier_curve(ccw_sorted_points, STEER=rad, smooth=edgy) 155 | plt.plot(x, y) 156 | 157 | plt.show() 158 | -------------------------------------------------------------------------------- /examples/generate_joseph_fourier_portrait.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example script to generate joseph fourier portrait 3 | 1) an input svg file is given and paths are represented in intermediate format 4 | 2) this intermediate format is fed to our drawing scripts "arrow_animation.py" or "evolution_demo.py" 5 | """ 6 | 7 | import cv2 8 | 9 | from core.generate_points import generate_points_from_svg, animate_points 10 | 11 | generate_points_from_svg("../data/fourier.svg") 12 | animate_points("../data/fourier.pts") 13 | print("Press any key to continue") 14 | cv2.waitKey() 15 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Fourier Series Animation 2 | This repo holds the code for [my medium article](www.go.om) on animating Fourier series. 3 | 4 | **To install the requirements**: 5 | ``` 6 | pip install -r requirements.txt 7 | ``` 8 | The directory structure for the repo: ⤵⤵ 9 | ``` 10 | ├── arrow_animation.py ➡ arrow animation generation script. 11 | ├── evolution_demo.py ➡ evolution animation generation script. 12 | ├── core ➡ core scripts and modules. 13 | │ ├── fourier_drawer.py ➡ for drawing Fourier coefficients using opencv 14 | │ ├── fourier_numerical_approximator.py ➡ finding coefficients. 15 | │ └── generate_points.py ➡ generate the PTS files. 16 | ├── data ➡ SVG files + PTS files, example fourier.svg. 17 | ├── demos ➡ demo GIFs for repo preview. 18 | └── example 19 | │ ├── bezier.py ➡ making random smooth curves. 20 | │ ├── generate_joseph_fourier_portrait.py ➡ generate the PTS file for fourier.svg 21 | ``` 22 | 23 | **How to use**: 24 | 1. To generate an arrow animation 25 | ``` 26 | python arrow_animation.py 27 | ``` 28 | This will generate the arrow animation for the points fed to ```FOUR.animate(points,...)``` method, check the examples in the script 29 | 2. To generate an arrow animation 30 | ``` 31 | evolution_demo.py 32 | ``` 33 | This will generate evolution animation for the points fed to ```FOUR.evolve(points,...)``` method, check the examples in the script 34 | 35 | # Fun Zone 🤖 36 | ![Cake](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/cake.gif) 37 | ![Eid](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/eid.gif) 38 | ![fourier arrow](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/fourier%20arrow.gif) 39 | ![fourier evolve](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/fourier%20evolve.gif) 40 | ![heart](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/heart.gif) 41 | ![mosque](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/mosque.gif) 42 | ![thanks](https://github.com/mohammed-elkomy/fourier-anim-python/blob/main/demos/thanks.gif) 43 | 44 | I don't own the rights for any of those images. 45 | 46 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==3.1.3 2 | scipy==1.4.1 3 | cmapy==0.6.6 4 | opencv_contrib_python==4.2.0.34 5 | svg.path==6.0 6 | joblib==0.14.1 7 | numpy==1.18.1 8 | --------------------------------------------------------------------------------